Kingdom.md MVP Plan

Goal: Build the Kingdom.md MVP — a login-less, read-only Laravel 13 web app that renders Markdown "kingdoms" (configured directories of .md files) beautifully and fast, with a file-tree sidebar, a kingdom dropdown, and a path-safe filesystem reader.

Approach: A clean Laravel 13 install (no starter kit), server-rendered Blade + light Alpine + Tailwind/Typography. A KingdomService owns all filesystem access and the security-critical traversal guard; a MarkdownRenderer wraps league/commonmark (frontmatter + GFM essentials). Three routes, one Blade shell. Pest feature tests against a committed fixture kingdom are the verification spine — the ../-traversal-rejection test guards the one security-critical path in a login-less app. Built on a git worktree off main, merged once the suite is green.

Design source: working/mvp-design.md (validated 2026-06-10).

Build status — 2026-06-17: Tasks 1–6 of 7 built on the session-2026-06-15 worktree (scaffold/Sail, config + boot validation, KingdomService + security guard, MarkdownRenderer, controllers + routes, and the Warm Editorial Blade views — see Task 6's "as built" note). Next: Task 7 (Pest feature tests) — the merge gate. Dev env: Docker/Sail app-only, PHP 8.4 — run in-container commands as -u sail (see "Dev environment" below); note the empty vendor-volume bootstrap in CLAUDE.md after a fresh up.


Isolate the build from main

This MVP is built on a git worktree off main, not directly on mainmain stays at the validated design until the build's tests pass, then the work merges back.

  • Before Task 1, create the build worktree from the repo root: git worktree add .worktrees/session-YYYY-MM-DD -b session-YYYY-MM-DD HEAD (today's date; branched from local HEAD, no remote needed). Work against that worktree's paths.
  • Add /.worktrees to the repo's .gitignore (Task 1 establishes the first .gitignore), so the worktree dir is never tracked.
  • Merge back to main only once Task 7's Pest suite is green (the MVP verification gate).

(Note: Yggdrasil's session bookends normally own the worktree lifecycle, but Kingdom.md is its own repo outside that tooling, so create/merge here are manual steps in this plan.)


Dev environment (as built 2026-06-15)

The build runs in Docker via Laravel Sail (app-only) — no MySQL/Redis/etc.; the MVP's SQLite DB is dormant. Settled while executing Task 1:

  • Runtime: Sail's PHP 8.4 runtime (compose.yamlruntimes/8.4, image sail-8.4/app). Sail defaults to bleeding-edge 8.5; pinned to 8.4 as the stable choice. App on port 8000 (APP_PORT), Vite on 5173.
  • Running commands: Brad uses vendor/bin/sail … in WSL (the project CLAUDE.md convention). Claude's automation drives the equivalent docker compose … -u sail from PowerShell (the sail bash wrapper only runs under real WSL, not PowerShell or Git Bash). Either way, all in-container commands run as the sail user (uid 1000).
  • Permissions (important): the skeleton was bootstrapped through a throwaway composer container as root, but the web process runs as sail — root-owned files trigger a tempnam/500. Fixed by chown -R sail:sail; keep running everything as sail so it doesn't recur.
  • Laravel Boost is installed (dev) — CLAUDE.md guidelines + a laravel-best-practices skill (consult its rules/ for Tasks 3–6) + .mcp.json/boost.json. Run boost:update after dependency changes. (The Boost MCP server isn't wired into Claude's session; the offline skill rules are.)

Testing approach

Not full TDD — but we keep the red → green → refactor benefit where it pays off:

  • Test-first for the pure-logic units — Task 3 (KingdomService traversal/tree) and Task 4 (MarkdownRenderer): write the unit test before the implementation, so the spec is sharp and the guard is provably exercised.
  • Negative control on every test, everywhere — confirm each test fails first (red) against not-yet-built or deliberately-bypassed code before making it pass (green). A test never seen to fail proves nothing. This applies to the Task 7 feature tests too — including confirming the traversal-rejection test actually fails if the guard is disabled.
  • Views (Task 6) are verified visually, not via TDD — the bar there is the human-judged "elegant, minimal, fast," not an assertion.

(Test-after fits the route/integration and view layers; test-first is reserved for the correctness-critical logic. Adjust toward full TDD or full test-after if preferred.)


Task 1: Scaffold the Laravel 13 app

Touches: the worktree repo root — composer.json, app/, config/, routes/, resources/, tests/, .env / .env.example, .gitignore, package.json, vite.config.js, compose.yaml, database/database.sqlite.

Kingdom.md's repo is a near-empty greenfield repo (working/, .gitattributes, .git only), so the Laravel skeleton must be installed into an existing, non-empty repo without clobbering .git, .gitattributes, or working/:

  1. Install Laravel 13 into a temp dir, then graft it in. Run composer create-project laravel/laravel:^13.0 ../kingdom-tmp (outside the repo), then move the generated files into the worktree root, preserving the existing .git, .gitattributes, and working/. Merge Laravel's generated .gitignore with our needs and add /.worktrees to it. Delete the temp dir.
  2. Portable, dormant DB (design open-item #3 → resolved: configure portably, no domain migrations). Set .env DB_CONNECTION=sqlite; create the empty database/database.sqlite. Do not author any domain migrations — the DB is dormant at MVP (user/group/role tables arrive with the auth tier). Leave the framework's default migrations in place but they need not be run for the viewer.
  3. Pest. composer require pestphp/pest --dev then install Pest (./vendor/bin/pest --init / the Pest v3 install path) so ./vendor/bin/pest runs.
  4. Front-end baseline. Install via npm: tailwindcss, @tailwindcss/typography, alpinejs. Wire Tailwind into resources/css/app.css (Tailwind directives) and enable the typography plugin in tailwind.config.js; import Alpine in resources/js/app.js and start it. Confirm Vite builds.
  5. App identity. Set APP_NAME="Kingdom.md" in .env / .env.example.

Per personal project-conventions: every env var added here is mirrored into .env.example with a safe placeholder (no secrets), kept in sync with .env.

Done when: the app boots to the Laravel welcome page; Pest runs green on the default example test; Vite compiles Tailwind + Alpine with no errors. Confirm by: run and present all three — php artisan about (expect Laravel 13.x), ./vendor/bin/pest (expect passing), npm run build (expect a successful Vite build).

Built 2026-06-15. Done via the Sail/Docker environment above (Composer ran in a throwaway container, not locally). All three Confirm-bys passed — Laravel 13.15, PHP 8.4, Pest 2 green, Vite build clean. Deviations from the steps above: (1) the skeleton ships Tailwind v4 (not v3), so step 4's typography plugin was enabled via @plugin '@tailwindcss/typography'; in resources/css/app.css — there is no tailwind.config.js; (2) the .env DB block was reverted from Sail's MySQL default back to SQLite; (3) WWWUSER/WWWGROUP added to .env (+.env.example) so docker compose substitutes them; (4) Laravel Boost added (see Dev environment).


Task 2: Kingdom config + fail-loud boot validation

Touches: config/kingdom.php, app/Providers/AppServiceProvider.php.

  1. Config registry — hardcoded for MVP. Create config/kingdom.php returning a hardcoded name→path map (literal absolute paths, edited in the file — no env(), no .env vars yet):

    return [
        // name => absolute filesystem path (hardcoded for MVP; edit here to add a kingdom)
        'kingdoms' => [
            'docs' => '/absolute/path/to/a/markdown/dir',
        ],
    ];
    

    The proper configuration mechanism (env-driven, multi-kingdom at deploy time, Docker) is deliberately deferred — see ## Deferred. Hardcoding keeps the kingdom abstraction (tree / dropdown / routes are unchanged) while skipping config machinery the MVP doesn't need. The map can hold more than one entry, so the dropdown still works.

  2. Default-kingdom resolution (design open-item #4 → resolved: keep it dead simple). No default config key at MVP — the / redirect always targets the first configured kingdom. The service exposes a small defaultKingdom() helper (finalized in Task 3) that returns the first entry, 404-ing if none is configured.

  3. Boot-time validation — fail loud, not at request time. In AppServiceProvider::boot(), iterate the configured kingdoms; for any whose path is missing, empty, or not a directory, throw a clear exception naming the offending kingdom (e.g. Kingdom "docs" path does not exist: /bad/path). A misconfigured deployment fails at boot, never mysteriously mid-request.

Done when: with a valid kingdom path configured the app boots; with a bad/missing path, boot throws an exception naming the offending kingdom. Confirm by: a quick smoke — set the hardcoded path to a real dir (boots), then to a bogus dir and hit any route (expect the named boot exception). Present both. (Formalized as a Pest assertion in Task 7.)


Task 3: KingdomService — tree, path safety, file read

Touches: app/Services/KingdomService.php, tests/Unit/KingdomServiceTest.php.

This service owns all filesystem access and the one security-critical path in a login-less app. Implement these methods:

  • kingdoms(): array — the configured name→path map (from config('kingdom.kingdoms')).

  • defaultKingdom(): string — the first configured kingdom (array_key_first() of the config('kingdom.kingdoms') map); throw not-found (caller → 404) if none is configured.

  • rootFor(string $kingdom): string — the configured absolute root for a kingdom; throw a not-found exception for an unknown kingdom name (caller → 404).

  • tree(string $kingdom): array — recursively scan the kingdom root and return a nested structure of nodes ['name', 'path' (relative), 'type' => 'dir'|'file', 'children']. Include directories and .md files only; ignore any hidden entry — anything whose name starts with . (covers .git, .obsidian, and other dotfiles). That dotfile rule is the whole filter for the MVP; other system dirs (e.g. node_modules) can be added to the ignore list later if a real kingdom needs it. Sort folders first, then files, alphabetically.

  • resolve(string $kingdom, string $relativePath): stringthe traversal guard. Join the kingdom root + the requested relative path, then canonicalize both with realpath(). Reject (throw not-found) if the requested file's real path does not sit inside the real root — guard with a prefix check:

    $realRoot = realpath($root);
    $realFile = realpath($root . DIRECTORY_SEPARATOR . $relativePath);
    if ($realFile === false
        || !str_starts_with($realFile, $realRoot . DIRECTORY_SEPARATOR)) {
        throw new KingdomFileNotFound($kingdom, $relativePath); // caller → 404
    }
    

    This rejects ../ traversal and symlink escapes (realpath follows symlinks). Also reject paths that don't resolve to a real file.

    MVP targets Ubuntu only, so paths are POSIX (/-separated, case-sensitive) — the URL {path} and realpath() agree, and no separator/casing normalization is needed. (If a Windows host is ever targeted, normalize separators + casing before the realpath compare — not now.)

  • read(string $kingdom, string $relativePath): stringresolve() then return the file contents.

Done when: the service builds a correct tree (folders + .md only, no hidden/system entries), resolves valid paths, and rejects every traversal attempt. Confirm by: ./vendor/bin/pest tests/Unit/KingdomServiceTest.php — assert the tree omits .git/.obsidian/dotfiles, a ../-style path throws, a valid file reads. Present the output. (Unit test written test-first — confirm it's red before the guard exists, green after.)


Task 4: MarkdownRenderer — commonmark + frontmatter + GFM essentials

Touches: app/Services/MarkdownRenderer.php, composer.json (league/commonmark), tests/Unit/MarkdownRendererTest.php.

  1. composer require league/commonmark.
  2. Build the configured environment (design open-item #1 → resolved: this exact extension set). Construct a CommonMark Environment with:
    • CommonMarkCoreExtension
    • FrontMatterExtension (splits YAML frontmatter from the body)
    • GFM essentials: TableExtension, TaskListExtension, StrikethroughExtension, AutolinkExtension
    • Safe config for a login-less app: 'html_input' => 'escape', 'allow_unsafe_links' => false. Code fences render as plain <pre><code> (no syntax highlighting — that's an early roadmap follow, not MVP).
  3. render(string $markdown): array returning ['frontmatter' => array, 'html' => string]. Use a League\CommonMark\MarkdownConverter built on the environment and call convert($markdown). The result is a RenderedContentInterface; when frontmatter is present it is specifically a League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter, whose getFrontMatter() returns the parsed YAML. So: instanceof RenderedContentWithFrontMatter ? $result->getFrontMatter() : [] for the frontmatter, and (string) $result for the HTML body.

Done when: rendering a sample containing a table, task list, strikethrough, autolink, and YAML frontmatter returns the parsed frontmatter array plus correct HTML; a fenced code block becomes plain monospace <pre><code> (no highlight markup). Confirm by: ./vendor/bin/pest tests/Unit/MarkdownRendererTest.php — assert frontmatter parsed, table/<table> present, code block is plain. Present the output. (Written test-first — red before the renderer exists, green after.)


Task 5: Controllers + routes

Touches: app/Http/Controllers/KingdomController.php, routes/web.php.

  1. Routes in routes/web.php:
    • GET /KingdomController@index, named home — redirect to the default kingdom.
    • GET /{kingdom}KingdomController@showKingdom, named kingdom.show.
    • GET /{kingdom}/{path}KingdomController@showFile, named kingdom.file, with the wildcard constraint ->where('path', '.*') so nested paths match.
  2. Controller (injects KingdomService + MarkdownRenderer):
    • index()redirect()->route('kingdom.show', $service->defaultKingdom()).
    • showKingdom($kingdom) → build the tree; if README.md or index.md exists at the root, render it (delegate to the show path); else render the empty state with the tree present. Unknown kingdom → abort(404).
    • showFile($kingdom, $path)resolve + read + render; return the show view with (kingdom, kingdoms list, tree, frontmatter, html, currentPath). Any not-found / traversal exception from the service → abort(404).

Done when: routes resolve; / redirects to the default kingdom; /{kingdom} opens README/index or the empty state; an unknown kingdom, a missing file, and a ../ traversal all return 404. Confirm by: present php artisan route:list (the three routes) plus a manual check of each outcome via the browser or curl (redirect, a rendered file, a 404). Fully formalized in Task 7.


Task 6: Blade views + the beautiful Tailwind baseline

Touches: resources/views/layout.blade.php, resources/views/partials/tree.blade.php, resources/views/show.blade.php, resources/views/partials/empty.blade.php, tailwind.config.js.

  1. layout.blade.php — the one shell. A slim top bar with the app name and an always-visible kingdom dropdown (a <select> whose change navigates to /{kingdom}, or an Alpine dropdown), a sidebar holding the file tree, and a reading pane (@yield('content')). Constrained, comfortable layout.
  2. partials/tree.blade.php — recursive. Render nested folders and .md file links (/{kingdom}/{path}). Folders collapse/expand via Alpine (x-data="{ open: true }" + x-show). Highlight the currentPath. Recurse the partial for children.
  3. show.blade.php — the reading view. A frontmatter metadata card (a styled box listing each frontmatter key → value) above the body, visually distinct from it; then the rendered HTML wrapped in a Tailwind Typography prose container (constrained width, comfortable line-height) so headings/lists/tables/quotes/code look polished out of the box. Frontmatter card is omitted when there's no frontmatter.
  4. partials/empty.blade.php — a gentle "pick a file from the tree" empty state.
  5. Confirm @tailwindcss/typography is registered in tailwind.config.js and prose is applied to the rendered body.

Done when: loading a kingdom shows the top bar + dropdown + collapsible tree + rendered file; the frontmatter card renders; tables/lists/quotes look polished via prose; code is clean monospace; switching kingdoms via the dropdown works; no layout jank or laggy JS. Confirm by: a manual visual smoke in the browser (present a screenshot or a precise description of each element) plus a clean npm run build. Visual quality is a human-judged checkpoint — this is the "elegant, minimal, fast" success bar.

Built 2026-06-17. The Warm Editorial views, against DESIGN.md + nav-design.md + design-prototype.html: layout.blade.php, partials/tree.blade.php (recursive), components/icon.blade.php, show.blade.php, partials/empty.blade.php, resources/css/app.css (prototype CSS ported), resources/js/{app,subnav}.js.

  • Tree architecture (the session's main design call): a recursive Blade partial (server-side recursion via self-@include) + a small Alpine island for the inline subnav + the official @alpinejs/collapse for the furl. Why: Alpine's x-for can't self-recurse (markup can't reference itself), so a pure-Alpine tree means imperative string-building or a custom directive — Blade recurses for free. Livewire rejected (round-trips every nav interaction; per-node overhead; the viewer is static/read-only). No maintained Alpine recursion plugin exists (x-template-outlet is an unmaintained 2024 blog snippet). Only the subnav — breadcrumb drill + directional slides, the single-column "Miller / drill-down" pattern — is client-rendered.
  • Deviations from the plan above: (1) used the prototype's hand-tuned .doc styles, not Tailwind prose (prose would fight the validated look; the plugin stays installed, unused on the body). (2) the topbar is the prototype's full chrome — search / notifications / sign-in are visual placeholders for future features. (3) added MarkdownRenderer::frontMatter() + FrontMatterYamlParser (date-aware — unquoted YAML dates stay readable strings, not Unix timestamps) feeding the sidebar hovercards (the controller builds a path→frontmatter map; reads every file per request — fine at small scale, a lazy-load candidate later). (4) Decision 10 (collapse-preserves-active-lineage) built, then revised to a gentler per-folder rule (see nav-design.md). (5) removed the starter-kit instrument-sans font bundling from vite.config.js; deleted the unused default welcome.blade.php.
  • Verification: structural (curl) by Claude; visual/interactive sign-off by the user (computer use was unavailable to Claude this session). User confirmed the look, subnav focus, hovercards, Decision 10.
  • Not in MVP scope (deferred): persisting tree/subnav expand state across navigations (see ## Deferred).

Task 7: Pest feature tests + fixture kingdom (the verification gate)

Touches: tests/Feature/KingdomViewerTest.php, tests/fixtures/kingdom/ (committed fixture), test env/config wiring.

  1. Fixture kingdom (design open-item #2 → resolved: these contents) under tests/fixtures/kingdom/:
    • README.md — simple body (the /{kingdom} auto-open target).
    • notes/sample.md — YAML frontmatter (name, description, type) + a table + a task list + strikethrough + an autolink (exercises the renderer).
    • an .obsidian/ directory and a .secret.md dotfile — both of which the tree must ignore (they exercise the dotfile-ignore rule; a real .git/ is filtered by the exact same rule, and can't be committed as a fixture anyway).
    • an empty/ kingdom dir (or a second configured empty kingdom) for the empty-state test.
  2. Wire a test kingdom at the fixture path via the test environment / a config override in the test bootstrap.
  3. Tests:
    1. tree-building lists README.md + notes/sample.md, and omits the dotfile entries (.obsidian/, .secret.md).
    2. GET /test/notes/sample.md200, response contains the rendered <table> and the frontmatter card's values.
    3. Security: GET /test/../../<something> and URL-encoded ..%2f variants → 404, never escaping the root.
    4. unknown kingdom → 404; missing file → 404; empty kingdom → the empty-state response.
    5. GET /redirect to the default kingdom.

Done when: the full Pest suite passes, including the traversal-rejection security test. Confirm by: ./vendor/bin/pest → all green; present the output. Before declaring green, run the negative control — confirm the traversal-rejection test actually fails when the guard is bypassed, so the security check is proven real, not vacuously passing. This is the MVP's headline verification gate — and the trigger to merge the build worktree back to main.


Deferred

The full post-MVP roadmap (syntax highlighting, asset serving, wikilinks/backlinks, the Fortify→Socialite→OIDC auth tier, RBAC, theming, Docker, caching, pluggable sources) is tracked authoritatively in the design doc's Roadmap section — not re-listed here. This section holds only items deferred from the MVP build itself:

  • 2026-06-11Syntax highlighting for code blocks. The design names this the first early follow-on after the MVP. Deliberately out of the MVP (plain monospace is honest and clean; highlighting needs fiddling to land well). Trigger: right after the MVP ships and feels good — it's the first roadmap pickup.

  • 2026-06-11Running framework migrations at MVP. The DB is configured portably but dormant; we author no domain migrations. Trigger: the local-auth tier (Fortify), which introduces the first real user/group/role tables.

  • 2026-06-11Kingdom path configuration mechanism (hardcoded at MVP). The kingdom map is hardcoded in config/kingdom.php for now; how operators should configure kingdoms is deferred to its own in-depth pass. Research already done (2026-06-11) for that pass: kingdoms are operator-defined, arbitrary-count config, so Laravel's static-map idiom (à la config/filesystems.php disks) is the wrong conceptual fit; the real choices are a prefix-scan of env vars (KINGDOM_*_PATH, the Docker-native shape) or a single JSON env var. Whatever we pick, the env reads must live inside config/kingdom.php (the env()-only-in-config rule), and the Docker packaging item needs an entrypoint that runs php artisan config:cache after env injection (cached config otherwise freezes stale values). Trigger: when we want real multi-kingdom/deploy-time config, or when the Docker roadmap item is taken up.

  • 2026-06-17Persist tree expand/collapse (+ subnav drill) state across page navigations. At MVP the nav is ephemeral client-side state, re-derived to the open doc on each full page load (nav-design.md Decision 8), so manual expansions reset on navigation. Want: expansions persist across page changes. Mechanism: client-side @alpinejs/persist (localStorage — safe under the login-less MVP), or naturally if nav becomes SPA-like (no full reload). Trigger: when we revisit nav statefulness or wire up Livewire.


Notes for the future generic Laravel agent

A few decisions here are Laravel-shaped, not Kingdom-specific — they're harvested into Yggdrasil's working/laravel.md for a future reusable Laravel project agent: the clean-install-not-starter-kit choice (Fortify/Socialite retrofit path), the realpath-prefix traversal guard (Task 3), the league/commonmark extension recipe (Task 4), and the Tailwind Typography prose "beautiful for free" baseline (Task 6). The richest reasoning lives in this project's design-doc Decision log — that's the generic agent's first thing to mine on any new project.