Add cards

This commit is contained in:
Víctor Falcón 2026-05-25 15:28:23 +02:00
parent 4eb15d3de6
commit 47927ddb4d
7 changed files with 801 additions and 130 deletions

View File

@ -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<string, string>
*/
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<string, string>}
*/
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) === '<div class="cards-wrapper">') {
$insideWrapper = true;
$wrapperCards = [];
continue;
}
if (! $insideWrapper) {
$output[] = $line;
continue;
}
if (! $insideCard && trim($line) === '<div class="card">') {
$insideCard = true;
$cardLines = [];
continue;
}
if (trim($line) === '</div>') {
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<int, string> $cards
*/
private function cardsHtml(array $cards): string
{
$items = collect($cards)
->filter()
->map(fn (string $card): string => '<section class="card">'.(string) Str::of($card)->markdown([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]).'</section>')
->implode('');
if ($items === '') {
return '';
}
return '<div class="cards-wrapper">'.$items.'</div>';
}
/**
* @param array<string, string> $cardBlocks
*/
private function replaceCardPlaceholders(string $html, array $cardBlocks): string
{
foreach ($cardBlocks as $placeholder => $cardHtml) {
$html = str_replace(["<p>{$placeholder}</p>", $placeholder], $cardHtml, $html);
}
return $html;
}
/**
* @return array<int, array{level: int, title: string, id: string}>
*/
@ -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<int, array{level: int, title: string, id: string}> $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(
["<p>{$placeholder}</p>", $placeholder],
$this->tocHtml($headings),
$this->tocHtml($headings, $locale),
$html,
);
}
@ -173,7 +333,7 @@ class DocumentationController extends Controller
/**
* @param array<int, array{level: int, title: string, id: string}> $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 '<nav class="documentation-toc" aria-label="Table of contents"><p>On this page</p><ol>'.$items.'</ol></nav>';
return '<nav class="documentation-toc" aria-label="Table of contents"><p>'.$this->tocTitle($locale).'</p><ol>'.$items.'</ol></nav>';
}
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<int, array{slug: string, title: string, url: string, active: bool}>
*/
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<int, array{locale: string, label: string, url: string, active: bool}>
*/
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();
}
}

View File

@ -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'),
],
],
],
];

View File

@ -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.

View File

@ -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.
<div class="cards-wrapper">
<div class="card">
### Expense
Money leaving your finances.
Examples:
- Groceries
- Rent
- Transport
- Subscriptions
- Taxes
</div>
<div class="card">
### Income
Money coming into your finances.
Examples:
- Salary
- Freelance income
- Refunds
- Dividends
- Interest
</div>
<div class="card">
### Transfer
Money moving between accounts you own.
Examples:
- Checking to savings
- Bank account to credit card
- Bank account to investment account
</div>
<div class="card">
### Savings
Money intentionally set aside.
Examples:
- Emergency fund
- House deposit
- Vacation fund
- Other money goals
</div>
<div class="card">
### Investment
Money going into assets or investment accounts.
Examples:
- Broker deposits
- Index funds
- Retirement contributions
- Crypto purchases
</div>
</div>
## 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.

View File

@ -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.
<div class="cards-wrapper">
<div class="card">
### Gasto
Dinero que sale de tus finanzas.
Ejemplos:
- Supermercado
- Alquiler
- Transporte
- Suscripciones
- Impuestos
</div>
<div class="card">
### Ingreso
Dinero que entra en tus finanzas.
Ejemplos:
- Salario
- Ingresos freelance
- Reembolsos
- Dividendos
- Intereses
</div>
<div class="card">
### 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
</div>
<div class="card">
### Ahorro
Dinero que apartas intencionadamente.
Ejemplos:
- Fondo de emergencia
- Entrada para una casa
- Fondo de vacaciones
- Otros objetivos de dinero
</div>
<div class="card">
### 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
</div>
</div>
## 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.

View File

@ -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<HTMLElement>(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<HTMLElement>('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 (
<article
ref={articleRef}
className="max-w-3xl [&_.card]:rounded-xl [&_.card]:border [&_.card]:border-black/10 [&_.card]:bg-black/[0.02] [&_.card]:p-5 dark:[&_.card]:border-white/10 dark:[&_.card]:bg-white/[0.03] [&_.card_h3]:mt-0 [&_.card_h3]:mb-3 [&_.card_p]:mb-4 [&_.card_ul]:mb-0 [&_.cards-wrapper]:my-8 [&_.cards-wrapper]:grid [&_.cards-wrapper]:grid-cols-1 [&_.cards-wrapper]:gap-4 md:[&_.cards-wrapper]:grid-cols-2 [&_.documentation-mermaid]:my-6 [&_.documentation-mermaid]:overflow-x-auto [&_.documentation-mermaid]:p-5 [&_.documentation-toc]:my-8 [&_.documentation-toc]:rounded-xl [&_.documentation-toc]:border [&_.documentation-toc]:border-black/10 [&_.documentation-toc]:bg-black/[0.02] [&_.documentation-toc]:p-5 dark:[&_.documentation-toc]:border-white/10 dark:[&_.documentation-toc]:bg-white/[0.03] [&_.documentation-toc_.toc-level-3]:pl-4 [&_.documentation-toc_.toc-number]:text-[#706f6c] dark:[&_.documentation-toc_.toc-number]:text-[#A1A09A] [&_.documentation-toc_a]:no-underline [&_.documentation-toc_ol]:m-0 [&_.documentation-toc_ol]:list-none [&_.documentation-toc_ol]:space-y-2 [&_.documentation-toc_ol]:p-0 [&_.documentation-toc_p]:mb-3 [&_.documentation-toc_p]:text-sm [&_.documentation-toc_p]:font-semibold [&_a]:font-medium [&_a]:text-[#1b1b18] [&_a]:underline dark:[&_a]:text-[#EDEDEC] [&_blockquote]:my-6 [&_blockquote]:rounded-xl [&_blockquote]:border-l-4 [&_blockquote]:border-[#1b1b18] [&_blockquote]:bg-black/[0.03] [&_blockquote]:px-5 [&_blockquote]:py-4 dark:[&_blockquote]:border-[#EDEDEC] dark:[&_blockquote]:bg-white/[0.04] [&_blockquote_p]:mb-0 [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-1.5 [&_code]:py-0.5 dark:[&_code]:bg-white/10 [&_h1]:mb-5 [&_h1]:text-4xl [&_h1]:leading-tight [&_h1]:font-semibold [&_h2]:mt-12 [&_h2]:mb-4 [&_h2]:border-t [&_h2]:border-black/10 [&_h2]:pt-8 [&_h2]:text-2xl [&_h2]:font-semibold dark:[&_h2]:border-white/10 [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-semibold [&_li]:pl-1 [&_ol]:mb-5 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_p]:mb-5 [&_p]:leading-7 [&_p]:text-[#706f6c] dark:[&_p]:text-[#A1A09A] [&_pre]:my-6 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-black/10 [&_pre]:bg-black/[0.03] [&_pre]:p-5 [&_pre]:text-sm dark:[&_pre]:border-white/10 dark:[&_pre]:bg-white/[0.04] [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:mb-5 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
export default function DocumentationShow({
document,
navigation,
languages,
}: DocumentationShowProps) {
const { appUrl } = usePage<SharedData>().props;
@ -33,7 +153,7 @@ export default function DocumentationShow({
<meta name="description" content={__(document.description)} />
<link
rel="canonical"
href={`${appUrl}/documentation/${document.slug}`}
href={`${appUrl}/documentation/${document.slug}?lang=${document.locale}`}
/>
<meta name="robots" content="index, follow" />
<meta property="og:title" content={__(document.title)} />
@ -44,7 +164,7 @@ export default function DocumentationShow({
<meta property="og:type" content="article" />
<meta
property="og:url"
content={`${appUrl}/documentation/${document.slug}`}
content={`${appUrl}/documentation/${document.slug}?lang=${document.locale}`}
/>
</Head>
@ -58,6 +178,25 @@ export default function DocumentationShow({
{__('\u2190 Back to home')}
</Link>
<div className="mb-8 flex flex-wrap gap-2">
{languages.map((language) => (
<Link
key={language.locale}
href={language.url}
className={
language.active
? 'rounded-full bg-[#1b1b18] px-3 py-1 text-xs font-medium text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
: 'rounded-full border border-black/10 px-3 py-1 text-xs font-medium text-[#706f6c] hover:text-[#1b1b18] dark:border-white/10 dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]'
}
aria-current={
language.active ? 'true' : undefined
}
>
{language.label}
</Link>
))}
</div>
<nav aria-label={__('Documentation')}>
<p className="mb-3 text-xs font-semibold tracking-[0.2em] text-[#706f6c] uppercase dark:text-[#A1A09A]">
{__('Documentation')}
@ -81,10 +220,7 @@ export default function DocumentationShow({
</aside>
<main className="min-w-0 flex-1">
<article
className="max-w-3xl [&_.documentation-toc]:my-8 [&_.documentation-toc]:rounded-xl [&_.documentation-toc]:border [&_.documentation-toc]:border-black/10 [&_.documentation-toc]:p-5 dark:[&_.documentation-toc]:border-white/10 [&_.documentation-toc_.toc-level-3]:pl-4 [&_.documentation-toc_.toc-number]:text-[#706f6c] dark:[&_.documentation-toc_.toc-number]:text-[#A1A09A] [&_.documentation-toc_a]:no-underline [&_.documentation-toc_ol]:m-0 [&_.documentation-toc_ol]:list-none [&_.documentation-toc_ol]:space-y-2 [&_.documentation-toc_ol]:p-0 [&_.documentation-toc_p]:mb-3 [&_.documentation-toc_p]:text-sm [&_.documentation-toc_p]:font-semibold [&_a]:font-medium [&_a]:text-[#1b1b18] [&_a]:underline dark:[&_a]:text-[#EDEDEC] [&_h1]:mb-5 [&_h1]:text-4xl [&_h1]:leading-tight [&_h1]:font-semibold [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:text-2xl [&_h2]:font-semibold [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-semibold [&_li]:pl-1 [&_p]:mb-5 [&_p]:leading-7 [&_p]:text-[#706f6c] dark:[&_p]:text-[#A1A09A] [&_ul]:mb-5 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
dangerouslySetInnerHTML={{ __html: document.html }}
/>
<MermaidDocumentationArticle html={document.html} />
</main>
</div>
</div>

View File

@ -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, '<p>En esta página</p>'))
);
});
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, '<nav class="documentation-toc"')
&& str_contains($html, 'href="#what-categories-do"')
&& str_contains($html, 'href="#expense"')
&& str_contains($html, '<span class="toc-number">1</span> What categories do')
&& str_contains($html, '<span class="toc-number">2.1</span> Expense')
&& str_contains($html, 'language-mermaid')
&& str_contains($html, 'flowchart TD')
&& str_contains($html, '<div class="cards-wrapper">')
&& str_contains($html, '<section class="card"><h3 id="expense">Expense</h3>')
&& ! str_contains($html, '<div class="card">')
&& str_contains($html, '<span class="toc-number">3</span> What categories do')
&& str_contains($html, '<span class="toc-number">4.1</span> Expense')
&& str_contains($html, '<h2 id="what-categories-do">')
&& str_contains($html, '<h3 id="expense">')
&& ! str_contains($html, 'href="#categories"'))
@ -48,7 +76,7 @@ it('replaces the table of contents placeholder with heading links', function ()
it('uses configured heading levels for the table of contents', function () {
config(['documentation.toc.levels' => [2]]);
$this->get(route('documentation.show', ['slug' => 'categories']))
$this->get(route('documentation.show', ['slug' => 'categories', 'lang' => 'en']))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
@ -63,12 +91,15 @@ it('returns not found for unknown documentation pages', function () {
$this->get('/documentation/unknown')->assertNotFound();
});
it('has markdown files for all configured documentation pages', function () {
it('has markdown files for all configured documentation pages and locales', function () {
$pages = config('documentation.pages');
$locales = array_keys(config('documentation.locales'));
expect($pages)->toBeArray()->not->toBeEmpty();
foreach ($pages as $page) {
expect(File::exists($page['file']))->toBeTrue();
foreach ($locales as $locale) {
expect(File::exists($page['file'][$locale]))->toBeTrue();
}
}
});