diff --git a/app/Http/Controllers/DocumentationController.php b/app/Http/Controllers/DocumentationController.php index c64e23dc..5f56596c 100644 --- a/app/Http/Controllers/DocumentationController.php +++ b/app/Http/Controllers/DocumentationController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use Inertia\Inertia; @@ -13,17 +14,20 @@ class DocumentationController extends Controller public function __invoke(?string $slug = null): Response { $slug ??= $this->defaultSlug(); - $page = $this->page($slug); + $locale = $this->locale(); + $page = $this->page($slug, $locale); $markdown = File::get($page['file']); return Inertia::render('documentation/show', [ 'document' => [ 'slug' => $slug, + 'locale' => $locale, 'title' => $page['title'], 'description' => $page['description'], - 'html' => $this->html($markdown), + 'html' => $this->html($markdown, $locale), ], - 'navigation' => $this->navigation($slug), + 'navigation' => $this->navigation($slug, $locale), + 'languages' => $this->languageLinks($slug, $locale), ]); } @@ -38,10 +42,44 @@ class DocumentationController extends Controller return $slug; } + private function locale(): string + { + $locale = App::currentLocale(); + + if (array_key_exists($locale, $this->supportedLocales())) { + return $locale; + } + + return $this->fallbackLocale(); + } + + private function fallbackLocale(): string + { + $locale = config('documentation.fallback_locale', 'en'); + + return is_string($locale) && $locale !== '' ? $locale : 'en'; + } + + /** + * @return array + */ + private function supportedLocales(): array + { + $locales = config('documentation.locales', []); + + if (! is_array($locales)) { + return []; + } + + return collect($locales) + ->mapWithKeys(fn (mixed $label, string $locale): array => [$locale => (string) $label]) + ->all(); + } + /** * @return array{title: string, description: string, file: string} */ - private function page(string $slug): array + private function page(string $slug, string $locale): array { $page = config("documentation.pages.{$slug}"); @@ -49,30 +87,151 @@ class DocumentationController extends Controller throw new NotFoundHttpException; } - if (! File::exists($page['file'])) { + $file = $this->localizedValue($page['file'], $locale); + + if (! File::exists($file)) { throw new NotFoundHttpException; } return [ - 'title' => (string) $page['title'], - 'description' => (string) $page['description'], - 'file' => (string) $page['file'], + 'title' => $this->localizedValue($page['title'], $locale), + 'description' => $this->localizedValue($page['description'], $locale), + 'file' => $file, ]; } - private function html(string $markdown): string + private function localizedValue(mixed $value, string $locale): string { + if (is_array($value)) { + $fallbackLocale = $this->fallbackLocale(); + $localized = $value[$locale] ?? $value[$fallbackLocale] ?? null; + + if (is_string($localized)) { + return $localized; + } + + throw new NotFoundHttpException; + } + + if (! is_string($value)) { + throw new NotFoundHttpException; + } + + return $value; + } + + private function html(string $markdown, string $locale): string + { + $cardBlocks = $this->extractCardBlocks($markdown); $headings = $this->headings($markdown); - $html = (string) Str::of($markdown)->markdown([ + $html = (string) Str::of($cardBlocks['markdown'])->markdown([ 'html_input' => 'strip', 'allow_unsafe_links' => false, ]); - $html = $this->replaceTocPlaceholder($html, $headings); + $html = $this->replaceTocPlaceholder($html, $headings, $locale); + $html = $this->replaceCardPlaceholders($html, $cardBlocks['html']); return $this->addHeadingIds($html, $headings); } + /** + * @return array{markdown: string, html: array} + */ + private function extractCardBlocks(string $markdown): array + { + $cardBlocks = []; + $output = []; + $lines = preg_split('/\R/', $markdown) ?: []; + $index = 0; + $insideWrapper = false; + $insideCard = false; + $wrapperCards = []; + $cardLines = []; + + foreach ($lines as $line) { + if (! $insideWrapper && trim($line) === '
') { + $insideWrapper = true; + $wrapperCards = []; + + continue; + } + + if (! $insideWrapper) { + $output[] = $line; + + continue; + } + + if (! $insideCard && trim($line) === '
') { + $insideCard = true; + $cardLines = []; + + continue; + } + + if (trim($line) === '
') { + if ($insideCard) { + $insideCard = false; + $wrapperCards[] = trim(implode("\n", $cardLines)); + $cardLines = []; + + continue; + } + + $placeholder = "DOCUMENTATION_CARDS_{$index}"; + $cardBlocks[$placeholder] = $this->cardsHtml($wrapperCards); + $output[] = $placeholder; + $insideWrapper = false; + $wrapperCards = []; + $index++; + + continue; + } + + if ($insideCard) { + $cardLines[] = $line; + } + } + + return [ + 'markdown' => implode("\n", $output), + 'html' => $cardBlocks, + ]; + } + + /** + * @param array $cards + */ + private function cardsHtml(array $cards): string + { + $items = collect($cards) + ->filter() + ->map(fn (string $card): string => '
'.(string) Str::of($card)->markdown([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]).'
') + ->implode(''); + + if ($items === '') { + return ''; + } + + return '
'.$items.'
'; + } + + /** + * @param array $cardBlocks + */ + private function replaceCardPlaceholders(string $html, array $cardBlocks): string + { + foreach ($cardBlocks as $placeholder => $cardHtml) { + $html = str_replace(["

{$placeholder}

", $placeholder], $cardHtml, $html); + } + + return $html; + } + /** * @return array */ @@ -118,6 +277,7 @@ class DocumentationController extends Controller ->map(fn (mixed $level): int => (int) $level) ->filter(fn (int $level): bool => $level >= 1 && $level <= 6) ->unique() + ->sort() ->values() ->all(); } @@ -155,7 +315,7 @@ class DocumentationController extends Controller /** * @param array $headings */ - private function replaceTocPlaceholder(string $html, array $headings): string + private function replaceTocPlaceholder(string $html, array $headings, string $locale): string { $placeholder = config('documentation.toc.placeholder', '{{TOC}}'); @@ -165,7 +325,7 @@ class DocumentationController extends Controller return str_replace( ["

{$placeholder}

", $placeholder], - $this->tocHtml($headings), + $this->tocHtml($headings, $locale), $html, ); } @@ -173,7 +333,7 @@ class DocumentationController extends Controller /** * @param array $headings */ - private function tocHtml(array $headings): string + private function tocHtml(array $headings, string $locale): string { if ($headings === []) { return ''; @@ -189,7 +349,12 @@ class DocumentationController extends Controller )) ->implode(''); - return ''; + return ''; + } + + private function tocTitle(string $locale): string + { + return e($this->localizedValue(config('documentation.toc.title', 'On this page'), $locale)); } /** @@ -198,29 +363,31 @@ class DocumentationController extends Controller */ private function numberedHeadings(array $headings): array { - $currentH2 = 0; - $currentH3 = 0; + $levels = $this->tocLevels(); + $counts = []; return collect($headings) - ->map(function (array $heading) use (&$currentH2, &$currentH3): array { - if ($heading['level'] === 2) { - $currentH2++; - $currentH3 = 0; - - return [...$heading, 'number' => (string) $currentH2]; - } - - if ($heading['level'] === 3) { - if ($currentH2 === 0) { - $currentH2 = 1; + ->map(function (array $heading) use ($levels, &$counts): array { + foreach ($levels as $level) { + if ($level > $heading['level']) { + unset($counts[$level]); } - - $currentH3++; - - return [...$heading, 'number' => "{$currentH2}.{$currentH3}"]; } - return [...$heading, 'number' => '']; + foreach ($levels as $level) { + if ($level < $heading['level'] && ! isset($counts[$level])) { + $counts[$level] = 1; + } + } + + $counts[$heading['level']] = ($counts[$heading['level']] ?? 0) + 1; + + $number = collect($levels) + ->filter(fn (int $level): bool => $level <= $heading['level'] && isset($counts[$level])) + ->map(fn (int $level): int => $counts[$level]) + ->implode('.'); + + return [...$heading, 'number' => $number]; }) ->all(); } @@ -264,7 +431,7 @@ class DocumentationController extends Controller /** * @return array */ - private function navigation(string $activeSlug): array + private function navigation(string $activeSlug, string $locale): array { $pages = config('documentation.pages', []); @@ -275,11 +442,27 @@ class DocumentationController extends Controller return collect($pages) ->map(fn (array $page, string $slug): array => [ 'slug' => $slug, - 'title' => (string) $page['title'], - 'url' => route('documentation.show', ['slug' => $slug], false), + 'title' => $this->localizedValue($page['title'] ?? '', $locale), + 'url' => route('documentation.show', ['slug' => $slug, 'lang' => $locale], false), 'active' => $slug === $activeSlug, ]) ->values() ->all(); } + + /** + * @return array + */ + private function languageLinks(string $slug, string $activeLocale): array + { + return collect($this->supportedLocales()) + ->map(fn (string $label, string $locale): array => [ + 'locale' => $locale, + 'label' => $label, + 'url' => route('documentation.show', ['slug' => $slug, 'lang' => $locale], false), + 'active' => $locale === $activeLocale, + ]) + ->values() + ->all(); + } } diff --git a/config/documentation.php b/config/documentation.php index 7134bd24..b484e1fb 100644 --- a/config/documentation.php +++ b/config/documentation.php @@ -2,17 +2,36 @@ return [ 'default' => 'categories', + 'fallback_locale' => 'en', + + 'locales' => [ + 'en' => 'English', + 'es' => 'Español', + ], 'toc' => [ 'placeholder' => '{{TOC}}', 'levels' => [2, 3], + 'title' => [ + 'en' => 'On this page', + 'es' => 'En esta página', + ], ], 'pages' => [ 'categories' => [ - 'title' => 'Categories', - 'description' => 'Learn how categories work in Whisper Money.', - 'file' => resource_path('docs/documentation/categories.md'), + 'title' => [ + 'en' => 'Categories', + 'es' => 'Categorías', + ], + 'description' => [ + 'en' => 'Learn how categories work in Whisper Money.', + 'es' => 'Aprende cómo funcionan las categorías en Whisper Money.', + ], + 'file' => [ + 'en' => resource_path('docs/documentation/en/categories.md'), + 'es' => resource_path('docs/documentation/es/categories.md'), + ], ], ], ]; diff --git a/resources/docs/documentation/categories.md b/resources/docs/documentation/categories.md deleted file mode 100644 index 3385eb43..00000000 --- a/resources/docs/documentation/categories.md +++ /dev/null @@ -1,76 +0,0 @@ -# Categories - -Categories explain what each transaction means. They make reports, budgets, and cashflow charts useful. - -{{TOC}} - -## What categories do - -Every transaction can have one category. Whisper Money uses that category to decide where the money appears in your summaries. - -For example: - -- Groceries, restaurants, and subscriptions usually count as expenses. -- Salary, refunds, and interest usually count as income. -- Savings and investment categories show money you are setting aside. -- Transfers help separate money moving between your own accounts from real spending or income. - -## Category types - -Categories have a type. The type controls how transactions are treated in reports. - -### Expense - -Use expense categories for money leaving your finances. Examples: groceries, rent, transport, subscriptions, and taxes. - -Expense categories appear in spending breakdowns and budget tracking. - -### Income - -Use income categories for money entering your finances. Examples: salary, freelance income, refunds, dividends, and interest. - -Income categories appear in cashflow reports and income summaries. - -### Transfer - -Use transfer categories when money moves between accounts you own. Examples: moving cash from checking to savings, paying a credit card from a bank account, or moving money to an investment account. - -Transfers should not be counted as normal income or spending. Categorizing them correctly keeps cashflow accurate. - -Transfer categories also have a cashflow direction: - -- Do not show: hides the transfer from cashflow. -- Show as cash inflow: shows the transfer as money coming in. -- Show as cash outflow: shows the transfer as money going out. - -### Savings - -Use savings categories when money is intentionally set aside. Examples: emergency fund, house deposit, vacation fund, or other goals. - -Savings categories help separate planned saving from everyday spending. - -### Investment - -Use investment categories when money goes into assets or investment accounts. Examples: brokerage deposits, index funds, retirement contributions, or crypto purchases. - -Investment categories help separate long-term wealth building from normal expenses. - -## Uncategorized transactions - -New imported transactions may start without a category. Review uncategorized transactions regularly so reports stay useful. - -If many similar transactions need the same category, create an automation rule so Whisper Money can categorize future transactions automatically. - -## Changing a category - -Changing a transaction category updates reports that include that transaction. This can affect spending totals, budget progress, income totals, savings totals, investment totals, and cashflow. - -Changing the category itself, such as its name or type, affects all transactions using that category. - -## Good category habits - -- Keep category names simple. -- Avoid creating several categories for the same kind of spending. -- Use transfer categories for account-to-account movement. -- Review uncategorized transactions before trusting monthly reports. -- Use automation rules for repeated merchants or descriptions. diff --git a/resources/docs/documentation/en/categories.md b/resources/docs/documentation/en/categories.md new file mode 100644 index 00000000..e890e8a0 --- /dev/null +++ b/resources/docs/documentation/en/categories.md @@ -0,0 +1,189 @@ +# Categories + +Categories help Whisper Money understand each transaction. Pick the right category, and your reports become easier to trust. + +{{TOC}} + +## Quick start + +If you only read one section, read this one. + +1. **Choose what the transaction is.** Is it spending, income, saving, investing, or a transfer? +2. **Use transfers for money moving between your own accounts.** This keeps reports from counting the same money twice. +3. **Review uncategorized transactions often.** Reports are only useful when transactions have the right category. +4. **Create automation rules for repeated transactions.** Let Whisper Money handle future matches for you. + +> Not sure what to pick? Start with the category type. The name can be adjusted later. + +## Category map + +Here is the basic idea: + +```mermaid +flowchart TD + transaction[Transaction] --> category[Category] + category --> reports[Reports] + category --> budgets[Budgets] + category --> cashflow[Cashflow] +``` + +A few examples: + +- 🛒 **Groceries** → Expense → spending report and budgets. +- 💼 **Salary** → Income → income and cashflow reports. +- 🏦 **Checking to savings** → Transfer or Savings → cashflow stays accurate. +- 📈 **Broker deposit** → Investment → investing is separated from daily spending. + +## What categories do + +Every transaction can have one category. + +Whisper Money uses that category to answer questions like: + +- How much did I spend on food? +- How much income came in this month? +- Am I saving or investing regularly? +- Is this real spending, or did I move money between my own accounts? + +## Category types + +Each category has a type. The type tells Whisper Money how to treat the transaction. + +
+ +
+### Expense + +Money leaving your finances. + +Examples: + +- Groceries +- Rent +- Transport +- Subscriptions +- Taxes + +
+ +
+### Income + +Money coming into your finances. + +Examples: + +- Salary +- Freelance income +- Refunds +- Dividends +- Interest + +
+ +
+### Transfer + +Money moving between accounts you own. + +Examples: + +- Checking to savings +- Bank account to credit card +- Bank account to investment account + +
+ +
+### Savings + +Money intentionally set aside. + +Examples: + +- Emergency fund +- House deposit +- Vacation fund +- Other money goals + +
+ +
+### Investment + +Money going into assets or investment accounts. + +Examples: + +- Broker deposits +- Index funds +- Retirement contributions +- Crypto purchases +
+
+ +## Transfers and cashflow direction + +Transfers can also have a cashflow direction. + +Choose the option that best matches how you want the transfer to appear: + +- **Do not show**: hide the transfer from cashflow. +- **Show as cash inflow**: show the transfer as money coming in. +- **Show as cash outflow**: show the transfer as money going out. + +For most account-to-account movement, **Do not show** is the safest choice. + +## Uncategorized transactions + +Imported transactions may start without a category. + +Try this routine: + +1. Open uncategorized transactions. +2. Assign the obvious ones first. +3. Leave confusing ones for later if needed. +4. Create automation rules for repeated merchants or descriptions. + +This keeps reports clean without turning categorization into a big chore. + +## Changing a category + +Changing a transaction category updates every report that includes that transaction. + +This can change: + +- Spending totals +- Budget progress +- Income totals +- Savings totals +- Investment totals +- Cashflow + +Changing the category itself, such as its name or type, affects all transactions using that category. + +## FAQ + +### What if I choose the wrong category? + +You can change it later. Reports update after the transaction is recategorized. + +### Should credit card payments be expenses? + +Usually no. If you already track the card purchases, the payment is money moving between your own accounts. Use a transfer category. + +### How many categories should I create? + +Start small. Too many categories make reports harder to read. Add more only when you need more detail. + +### When should I create an automation rule? + +Create one when the same merchant or description keeps getting the same category. + +## Good category habits + +- Keep names short and clear. +- Avoid duplicate categories for the same kind of spending. +- Use transfer categories for movement between your own accounts. +- Review uncategorized transactions before trusting monthly reports. +- Automate repeated merchants and descriptions. diff --git a/resources/docs/documentation/es/categories.md b/resources/docs/documentation/es/categories.md new file mode 100644 index 00000000..e1ee8ec0 --- /dev/null +++ b/resources/docs/documentation/es/categories.md @@ -0,0 +1,189 @@ +# Categorías + +Las categorías ayudan a Whisper Money a entender cada transacción. Elige bien la categoría y tus informes serán más fáciles de confiar. + +{{TOC}} + +## Inicio rápido + +Si solo lees una sección, lee esta. + +1. **Elige qué es la transacción.** ¿Es gasto, ingreso, ahorro, inversión o transferencia? +2. **Usa transferencias para dinero que se mueve entre tus propias cuentas.** Así evitas contar el mismo dinero dos veces. +3. **Revisa las transacciones sin categoría a menudo.** Los informes solo son útiles cuando las transacciones tienen la categoría correcta. +4. **Crea reglas de automatización para transacciones repetidas.** Deja que Whisper Money gestione futuras coincidencias. + +> ¿No sabes qué elegir? Empieza por el tipo de categoría. El nombre se puede ajustar después. + +## Mapa de categorías + +La idea básica es esta: + +```mermaid +flowchart TD + transaction[Transacción] --> category[Categoría] + category --> reports[Informes] + category --> budgets[Presupuestos] + category --> cashflow[Flujo de efectivo] +``` + +Algunos ejemplos: + +- 🛒 **Supermercado** → Gasto → informe de gastos y presupuestos. +- 💼 **Salario** → Ingreso → informes de ingresos y flujo de efectivo. +- 🏦 **Cuenta corriente a ahorro** → Transferencia o Ahorro → el flujo de efectivo sigue siendo preciso. +- 📈 **Depósito en broker** → Inversión → la inversión queda separada del gasto diario. + +## Qué hacen las categorías + +Cada transacción puede tener una categoría. + +Whisper Money usa esa categoría para responder preguntas como: + +- ¿Cuánto gasté en comida? +- ¿Cuánto ingreso entró este mes? +- ¿Estoy ahorrando o invirtiendo con regularidad? +- ¿Esto es un gasto real o moví dinero entre mis propias cuentas? + +## Tipos de categoría + +Cada categoría tiene un tipo. El tipo le dice a Whisper Money cómo tratar la transacción. + +
+ +
+### Gasto + +Dinero que sale de tus finanzas. + +Ejemplos: + +- Supermercado +- Alquiler +- Transporte +- Suscripciones +- Impuestos + +
+ +
+### Ingreso + +Dinero que entra en tus finanzas. + +Ejemplos: + +- Salario +- Ingresos freelance +- Reembolsos +- Dividendos +- Intereses + +
+ +
+### Transferencia + +Dinero que se mueve entre cuentas tuyas. + +Ejemplos: + +- Cuenta corriente a ahorro +- Cuenta bancaria a tarjeta de crédito +- Cuenta bancaria a inversión + +
+ +
+### Ahorro + +Dinero que apartas intencionadamente. + +Ejemplos: + +- Fondo de emergencia +- Entrada para una casa +- Fondo de vacaciones +- Otros objetivos de dinero + +
+ +
+### Inversión + +Dinero que va a activos o cuentas de inversión. + +Ejemplos: + +- Depósitos en broker +- Fondos indexados +- Aportaciones para jubilación +- Compras de cripto +
+
+ +## Transferencias y dirección de flujo de efectivo + +Las transferencias también pueden tener una dirección de flujo de efectivo. + +Elige la opción que mejor encaje con cómo quieres que aparezca la transferencia: + +- **No mostrar**: oculta la transferencia del flujo de efectivo. +- **Mostrar como entrada de efectivo**: muestra la transferencia como dinero que entra. +- **Mostrar como salida de efectivo**: muestra la transferencia como dinero que sale. + +Para la mayoría de movimientos entre tus propias cuentas, **No mostrar** es la opción más segura. + +## Transacciones sin categoría + +Las transacciones importadas pueden empezar sin categoría. + +Prueba esta rutina: + +1. Abre las transacciones sin categoría. +2. Asigna primero las más obvias. +3. Deja las confusas para más tarde si hace falta. +4. Crea reglas de automatización para comercios o descripciones repetidas. + +Así mantienes los informes limpios sin convertir la categorización en una tarea pesada. + +## Cambiar una categoría + +Cambiar la categoría de una transacción actualiza todos los informes que incluyen esa transacción. + +Esto puede cambiar: + +- Totales de gasto +- Progreso de presupuestos +- Totales de ingresos +- Totales de ahorro +- Totales de inversión +- Flujo de efectivo + +Cambiar la categoría en sí, como su nombre o tipo, afecta a todas las transacciones que usan esa categoría. + +## Preguntas frecuentes + +### ¿Qué pasa si elijo la categoría equivocada? + +Puedes cambiarla después. Los informes se actualizan cuando la transacción se recategoriza. + +### ¿Los pagos de tarjeta de crédito deberían ser gastos? + +Normalmente no. Si ya registras las compras de la tarjeta, el pago es dinero moviéndose entre tus propias cuentas. Usa una categoría de transferencia. + +### ¿Cuántas categorías debería crear? + +Empieza con pocas. Demasiadas categorías hacen que los informes sean más difíciles de leer. Añade más solo cuando necesites más detalle. + +### ¿Cuándo debería crear una regla de automatización? + +Crea una cuando el mismo comercio o descripción siempre acaba con la misma categoría. + +## Buenos hábitos con categorías + +- Usa nombres cortos y claros. +- Evita categorías duplicadas para el mismo tipo de gasto. +- Usa categorías de transferencia para movimientos entre tus propias cuentas. +- Revisa las transacciones sin categoría antes de confiar en los informes mensuales. +- Automatiza comercios y descripciones repetidas. diff --git a/resources/js/pages/documentation/show.tsx b/resources/js/pages/documentation/show.tsx index 04c8ac7a..9e8eab70 100644 --- a/resources/js/pages/documentation/show.tsx +++ b/resources/js/pages/documentation/show.tsx @@ -1,9 +1,12 @@ import { type SharedData } from '@/types'; import { __ } from '@/utils/i18n'; import { Head, Link, usePage } from '@inertiajs/react'; +import { ArrowUpIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; type DocumentationDocument = { slug: string; + locale: string; title: string; description: string; html: string; @@ -16,14 +19,131 @@ type NavigationItem = { active: boolean; }; +type LanguageLink = { + locale: string; + label: string; + url: string; + active: boolean; +}; + type DocumentationShowProps = { document: DocumentationDocument; navigation: NavigationItem[]; + languages: LanguageLink[]; }; +type MermaidModule = { + default: { + initialize: (options: { startOnLoad: boolean; theme: string }) => void; + render: (id: string, definition: string) => Promise<{ svg: string }>; + }; +}; + +function MermaidDocumentationArticle({ html }: { html: string }) { + const articleRef = useRef(null); + + useEffect(() => { + const article = articleRef.current; + + if (!article) { + return; + } + + const handleClick = (event: MouseEvent) => { + const link = (event.target as HTMLElement | null)?.closest( + 'a[href^="#"]', + ); + const hash = link?.getAttribute('href'); + + if (!hash || hash === '#') { + return; + } + + const target = document.getElementById(hash.slice(1)); + + if (!target) { + return; + } + + event.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + history.pushState(null, '', hash); + }; + + article.addEventListener('click', handleClick); + + return () => article.removeEventListener('click', handleClick); + }, [html]); + + useEffect(() => { + let cancelled = false; + + async function renderMermaidDiagrams() { + const article = articleRef.current; + + if (!article) { + return; + } + + const blocks = Array.from( + article.querySelectorAll('code.language-mermaid'), + ); + + if (blocks.length === 0) { + return; + } + + const { default: mermaid } = (await import( + /* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs' + )) as MermaidModule; + + if (cancelled) { + return; + } + + mermaid.initialize({ + startOnLoad: false, + theme: document.documentElement.classList.contains('dark') + ? 'dark' + : 'default', + }); + + await Promise.all( + blocks.map(async (block, index) => { + const container = document.createElement('div'); + const source = block.textContent ?? ''; + const { svg } = await mermaid.render( + `documentation-mermaid-${index}-${crypto.randomUUID()}`, + source, + ); + + container.className = 'documentation-mermaid'; + container.innerHTML = svg; + block.closest('pre')?.replaceWith(container); + }), + ); + } + + void renderMermaidDiagrams(); + + return () => { + cancelled = true; + }; + }, [html]); + + return ( +
+ ); +} + export default function DocumentationShow({ document, navigation, + languages, }: DocumentationShowProps) { const { appUrl } = usePage().props; @@ -33,7 +153,7 @@ export default function DocumentationShow({ @@ -44,7 +164,7 @@ export default function DocumentationShow({ @@ -58,6 +178,25 @@ export default function DocumentationShow({ {__('\u2190 Back to home')} +
+ {languages.map((language) => ( + + {language.label} + + ))} +
+
diff --git a/tests/Feature/DocumentationTest.php b/tests/Feature/DocumentationTest.php index bab1a1a4..20f2890f 100644 --- a/tests/Feature/DocumentationTest.php +++ b/tests/Feature/DocumentationTest.php @@ -10,26 +10,49 @@ it('shows the default documentation page', function () { fn (AssertableInertia $page) => $page ->component('documentation/show') ->where('document.slug', 'categories') + ->where('document.locale', 'en') ->where('document.title', 'Categories') ->where('document.description', 'Learn how categories work in Whisper Money.') ->where('navigation.0.active', true) + ->where('languages.0.active', true) + ->where('languages.1.url', '/documentation/categories?lang=es') ); }); -it('shows the categories documentation page', function () { - $this->get(route('documentation.show', ['slug' => 'categories'])) +it('shows the English categories documentation page', function () { + $this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'en'])) ->assertOk() ->assertInertia( fn (AssertableInertia $page) => $page ->component('documentation/show') ->where('document.slug', 'categories') + ->where('document.locale', 'en') ->where('document.title', 'Categories') - ->where('navigation.0.url', '/documentation/categories') + ->where('navigation.0.url', '/documentation/categories?lang=en') + ); +}); + +it('shows the Spanish categories documentation page', function () { + $this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'es'])) + ->assertOk() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('documentation/show') + ->where('document.slug', 'categories') + ->where('document.locale', 'es') + ->where('document.title', 'Categorías') + ->where('document.description', 'Aprende cómo funcionan las categorías en Whisper Money.') + ->where('navigation.0.title', 'Categorías') + ->where('languages.0.url', '/documentation/categories?lang=en') + ->where('languages.1.active', true) + ->where('document.html', fn (string $html): bool => str_contains($html, 'Qué hacen las categorías') + && str_contains($html, 'href="#que-hacen-las-categorias"') + && str_contains($html, '

En esta página

')) ); }); it('replaces the table of contents placeholder with heading links', function () { - $this->get(route('documentation.show', ['slug' => 'categories'])) + $this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'en'])) ->assertOk() ->assertInertia( fn (AssertableInertia $page) => $page @@ -37,8 +60,13 @@ it('replaces the table of contents placeholder with heading links', function () && str_contains($html, '