documentation
This commit is contained in:
parent
af661f72f2
commit
4eb15d3de6
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue