documentation

This commit is contained in:
Víctor Falcón 2026-05-25 14:53:03 +02:00
parent af661f72f2
commit 4eb15d3de6
6 changed files with 550 additions and 0 deletions

View File

@ -0,0 +1,285 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class DocumentationController extends Controller
{
public function __invoke(?string $slug = null): Response
{
$slug ??= $this->defaultSlug();
$page = $this->page($slug);
$markdown = File::get($page['file']);
return Inertia::render('documentation/show', [
'document' => [
'slug' => $slug,
'title' => $page['title'],
'description' => $page['description'],
'html' => $this->html($markdown),
],
'navigation' => $this->navigation($slug),
]);
}
private function defaultSlug(): string
{
$slug = config('documentation.default');
if (! is_string($slug) || $slug === '') {
throw new NotFoundHttpException;
}
return $slug;
}
/**
* @return array{title: string, description: string, file: string}
*/
private function page(string $slug): array
{
$page = config("documentation.pages.{$slug}");
if (! is_array($page) || ! isset($page['title'], $page['description'], $page['file'])) {
throw new NotFoundHttpException;
}
if (! File::exists($page['file'])) {
throw new NotFoundHttpException;
}
return [
'title' => (string) $page['title'],
'description' => (string) $page['description'],
'file' => (string) $page['file'],
];
}
private function html(string $markdown): string
{
$headings = $this->headings($markdown);
$html = (string) Str::of($markdown)->markdown([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$html = $this->replaceTocPlaceholder($html, $headings);
return $this->addHeadingIds($html, $headings);
}
/**
* @return array<int, array{level: int, title: string, id: string}>
*/
private function headings(string $markdown): array
{
preg_match_all('/^(#{1,6})\s+(.+?)\s*#*\s*$/m', $markdown, $matches, PREG_SET_ORDER);
$headings = [];
$usedSlugs = [];
$levels = $this->tocLevels();
foreach ($matches as $match) {
$level = strlen($match[1]);
if (! in_array($level, $levels, true)) {
continue;
}
$title = $this->plainHeadingText($match[2]);
$headings[] = [
'level' => $level,
'title' => $title,
'id' => $this->uniqueHeadingId($title, $usedSlugs),
];
}
return $headings;
}
/**
* @return array<int, int>
*/
private function tocLevels(): array
{
$levels = config('documentation.toc.levels', [2, 3]);
if (! is_array($levels)) {
return [2, 3];
}
return collect($levels)
->map(fn (mixed $level): int => (int) $level)
->filter(fn (int $level): bool => $level >= 1 && $level <= 6)
->unique()
->values()
->all();
}
private function plainHeadingText(string $heading): string
{
$html = (string) Str::of($heading)->inlineMarkdown([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/**
* @param array<string, int> $usedSlugs
*/
private function uniqueHeadingId(string $title, array &$usedSlugs): string
{
$base = Str::slug($title);
if ($base === '') {
$base = 'section';
}
$usedSlugs[$base] = ($usedSlugs[$base] ?? 0) + 1;
if ($usedSlugs[$base] === 1) {
return $base;
}
return "{$base}-{$usedSlugs[$base]}";
}
/**
* @param array<int, array{level: int, title: string, id: string}> $headings
*/
private function replaceTocPlaceholder(string $html, array $headings): string
{
$placeholder = config('documentation.toc.placeholder', '{{TOC}}');
if (! is_string($placeholder) || $placeholder === '') {
return $html;
}
return str_replace(
["<p>{$placeholder}</p>", $placeholder],
$this->tocHtml($headings),
$html,
);
}
/**
* @param array<int, array{level: int, title: string, id: string}> $headings
*/
private function tocHtml(array $headings): string
{
if ($headings === []) {
return '';
}
$items = collect($this->numberedHeadings($headings))
->map(fn (array $heading): string => sprintf(
'<li class="toc-level-%d"><a href="#%s"><span class="toc-number">%s</span> %s</a></li>',
$heading['level'],
e($heading['id']),
e($heading['number']),
e($heading['title']),
))
->implode('');
return '<nav class="documentation-toc" aria-label="Table of contents"><p>On this page</p><ol>'.$items.'</ol></nav>';
}
/**
* @param array<int, array{level: int, title: string, id: string}> $headings
* @return array<int, array{level: int, title: string, id: string, number: string}>
*/
private function numberedHeadings(array $headings): array
{
$currentH2 = 0;
$currentH3 = 0;
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;
}
$currentH3++;
return [...$heading, 'number' => "{$currentH2}.{$currentH3}"];
}
return [...$heading, 'number' => ''];
})
->all();
}
/**
* @param array<int, array{level: int, title: string, id: string}> $headings
*/
private function addHeadingIds(string $html, array $headings): string
{
$levels = $this->tocLevels();
if ($headings === [] || $levels === []) {
return $html;
}
$levelPattern = implode('', $levels);
$headingIndex = 0;
return (string) preg_replace_callback(
"/<h([{$levelPattern}])>(.*?)<\/h\\1>/s",
function (array $match) use ($headings, &$headingIndex): string {
$heading = $headings[$headingIndex] ?? null;
$headingIndex++;
if ($heading === null) {
return $match[0];
}
return sprintf(
'<h%d id="%s">%s</h%d>',
(int) $match[1],
e($heading['id']),
$match[2],
(int) $match[1],
);
},
$html,
);
}
/**
* @return array<int, array{slug: string, title: string, url: string, active: bool}>
*/
private function navigation(string $activeSlug): array
{
$pages = config('documentation.pages', []);
if (! is_array($pages)) {
return [];
}
return collect($pages)
->map(fn (array $page, string $slug): array => [
'slug' => $slug,
'title' => (string) $page['title'],
'url' => route('documentation.show', ['slug' => $slug], false),
'active' => $slug === $activeSlug,
])
->values()
->all();
}
}

18
config/documentation.php Normal file
View File

@ -0,0 +1,18 @@
<?php
return [
'default' => 'categories',
'toc' => [
'placeholder' => '{{TOC}}',
'levels' => [2, 3],
],
'pages' => [
'categories' => [
'title' => 'Categories',
'description' => 'Learn how categories work in Whisper Money.',
'file' => resource_path('docs/documentation/categories.md'),
],
],
];

View File

@ -0,0 +1,76 @@
# 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,93 @@
import { type SharedData } from '@/types';
import { __ } from '@/utils/i18n';
import { Head, Link, usePage } from '@inertiajs/react';
type DocumentationDocument = {
slug: string;
title: string;
description: string;
html: string;
};
type NavigationItem = {
slug: string;
title: string;
url: string;
active: boolean;
};
type DocumentationShowProps = {
document: DocumentationDocument;
navigation: NavigationItem[];
};
export default function DocumentationShow({
document,
navigation,
}: DocumentationShowProps) {
const { appUrl } = usePage<SharedData>().props;
return (
<>
<Head title={__(document.title)}>
<meta name="description" content={__(document.description)} />
<link
rel="canonical"
href={`${appUrl}/documentation/${document.slug}`}
/>
<meta name="robots" content="index, follow" />
<meta property="og:title" content={__(document.title)} />
<meta
property="og:description"
content={__(document.description)}
/>
<meta property="og:type" content="article" />
<meta
property="og:url"
content={`${appUrl}/documentation/${document.slug}`}
/>
</Head>
<div className="min-h-screen bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
<div className="mx-auto flex max-w-6xl flex-col gap-10 px-6 py-10 lg:flex-row lg:px-8 lg:py-16">
<aside className="lg:w-64 lg:shrink-0">
<Link
href="/"
className="mb-8 inline-block text-sm text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]"
>
{__('\u2190 Back to home')}
</Link>
<nav aria-label={__('Documentation')}>
<p className="mb-3 text-xs font-semibold tracking-[0.2em] text-[#706f6c] uppercase dark:text-[#A1A09A]">
{__('Documentation')}
</p>
<div className="flex flex-col gap-1">
{navigation.map((item) => (
<Link
key={item.slug}
href={item.url}
className={
item.active
? 'rounded-lg bg-[#1b1b18] px-3 py-2 text-sm font-medium text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
: 'rounded-lg px-3 py-2 text-sm text-[#706f6c] hover:bg-black/5 hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:bg-white/10 dark:hover:text-[#EDEDEC]'
}
>
{__(item.title)}
</Link>
))}
</div>
</nav>
</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 }}
/>
</main>
</div>
</div>
</>
);
}

View File

@ -4,6 +4,7 @@ use App\Http\Controllers\AccountController;
use App\Http\Controllers\BudgetController;
use App\Http\Controllers\CashflowController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\DocumentationController;
use App\Http\Controllers\LoanDetailController;
use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\OpenBanking\AccountMappingController;
@ -87,6 +88,9 @@ Route::get('terms', function () {
return Inertia::render('terms');
})->name('terms');
Route::get('documentation', DocumentationController::class)->name('documentation.index');
Route::get('documentation/{slug}', DocumentationController::class)->name('documentation.show');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('subscribe', [SubscriptionController::class, 'index'])->name('subscribe');
Route::get('subscribe/checkout', [SubscriptionController::class, 'checkout'])->name('subscribe.checkout');

View File

@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\File;
use Inertia\Testing\AssertableInertia;
it('shows the default documentation page', function () {
$this->get(route('documentation.index'))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
->component('documentation/show')
->where('document.slug', 'categories')
->where('document.title', 'Categories')
->where('document.description', 'Learn how categories work in Whisper Money.')
->where('navigation.0.active', true)
);
});
it('shows the categories documentation page', function () {
$this->get(route('documentation.show', ['slug' => 'categories']))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
->component('documentation/show')
->where('document.slug', 'categories')
->where('document.title', 'Categories')
->where('navigation.0.url', '/documentation/categories')
);
});
it('replaces the table of contents placeholder with heading links', function () {
$this->get(route('documentation.show', ['slug' => 'categories']))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
->where('document.html', fn (string $html): bool => ! str_contains($html, '{{TOC}}')
&& 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, '<h2 id="what-categories-do">')
&& str_contains($html, '<h3 id="expense">')
&& ! str_contains($html, 'href="#categories"'))
);
});
it('uses configured heading levels for the table of contents', function () {
config(['documentation.toc.levels' => [2]]);
$this->get(route('documentation.show', ['slug' => 'categories']))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
->where('document.html', fn (string $html): bool => str_contains($html, 'href="#what-categories-do"')
&& ! str_contains($html, 'href="#expense"')
&& str_contains($html, '<h2 id="what-categories-do">')
&& ! str_contains($html, '<h3 id="expense">'))
);
});
it('returns not found for unknown documentation pages', function () {
$this->get('/documentation/unknown')->assertNotFound();
});
it('has markdown files for all configured documentation pages', function () {
$pages = config('documentation.pages');
expect($pages)->toBeArray()->not->toBeEmpty();
foreach ($pages as $page) {
expect(File::exists($page['file']))->toBeTrue();
}
});