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-15worktree (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 inCLAUDE.mdafter a freshup.
Isolate the build from main
This MVP is built on a git worktree off main, not directly on main — main 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 localHEAD, no remote needed). Work against that worktree's paths. - Add
/.worktreesto the repo's.gitignore(Task 1 establishes the first.gitignore), so the worktree dir is never tracked. - Merge back to
mainonly 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.yaml→runtimes/8.4, imagesail-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 projectCLAUDE.mdconvention). Claude's automation drives the equivalentdocker compose … -u sailfrom PowerShell (thesailbash wrapper only runs under real WSL, not PowerShell or Git Bash). Either way, all in-container commands run as thesailuser (uid 1000). - Permissions (important): the skeleton was bootstrapped through a throwaway
composercontainer as root, but the web process runs assail— root-owned files trigger atempnam/500. Fixed bychown -R sail:sail; keep running everything assailso it doesn't recur. - Laravel Boost is installed (dev) —
CLAUDE.mdguidelines + alaravel-best-practicesskill (consult itsrules/for Tasks 3–6) +.mcp.json/boost.json. Runboost:updateafter 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 (
KingdomServicetraversal/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/:
- 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, andworking/. Merge Laravel's generated.gitignorewith our needs and add/.worktreesto it. Delete the temp dir. - Portable, dormant DB (design open-item #3 → resolved: configure portably, no domain
migrations). Set
.envDB_CONNECTION=sqlite; create the emptydatabase/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. - Pest.
composer require pestphp/pest --devthen install Pest (./vendor/bin/pest --init/ the Pest v3 install path) so./vendor/bin/pestruns. - Front-end baseline. Install via npm:
tailwindcss,@tailwindcss/typography,alpinejs. Wire Tailwind intoresources/css/app.css(Tailwind directives) and enable the typography plugin intailwind.config.js; import Alpine inresources/js/app.jsand start it. Confirm Vite builds. - 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.
-
Config registry — hardcoded for MVP. Create
config/kingdom.phpreturning a hardcoded name→path map (literal absolute paths, edited in the file — noenv(), no.envvars 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. -
Default-kingdom resolution (design open-item #4 → resolved: keep it dead simple). No
defaultconfig key at MVP — the/redirect always targets the first configured kingdom. The service exposes a smalldefaultKingdom()helper (finalized in Task 3) that returns the first entry, 404-ing if none is configured. -
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 (fromconfig('kingdom.kingdoms')). -
defaultKingdom(): string— the first configured kingdom (array_key_first()of theconfig('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.mdfiles 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): string— the traversal guard. Join the kingdom root + the requested relative path, then canonicalize both withrealpath(). 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}andrealpath()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): string—resolve()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.
composer require league/commonmark.- Build the configured environment (design open-item #1 → resolved: this exact extension
set). Construct a CommonMark
Environmentwith:CommonMarkCoreExtensionFrontMatterExtension(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).
render(string $markdown): arrayreturning['frontmatter' => array, 'html' => string]. Use aLeague\CommonMark\MarkdownConverterbuilt on the environment and callconvert($markdown). The result is aRenderedContentInterface; when frontmatter is present it is specifically aLeague\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter, whosegetFrontMatter()returns the parsed YAML. So:instanceof RenderedContentWithFrontMatter ? $result->getFrontMatter() : []for the frontmatter, and(string) $resultfor 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.
- Routes in
routes/web.php:GET /→KingdomController@index, namedhome— redirect to the default kingdom.GET /{kingdom}→KingdomController@showKingdom, namedkingdom.show.GET /{kingdom}/{path}→KingdomController@showFile, namedkingdom.file, with the wildcard constraint->where('path', '.*')so nested paths match.
- Controller (injects
KingdomService+MarkdownRenderer):index()→redirect()->route('kingdom.show', $service->defaultKingdom()).showKingdom($kingdom)→ build the tree; ifREADME.mdorindex.mdexists 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 theshowview 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.
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.partials/tree.blade.php— recursive. Render nested folders and.mdfile links (/{kingdom}/{path}). Folders collapse/expand via Alpine (x-data="{ open: true }"+x-show). Highlight thecurrentPath. Recurse the partial for children.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 Typographyprosecontainer (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.partials/empty.blade.php— a gentle "pick a file from the tree" empty state.- Confirm
@tailwindcss/typographyis registered intailwind.config.jsandproseis 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/collapsefor the furl. Why: Alpine'sx-forcan'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-outletis 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
.docstyles, not Tailwindprose(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) addedMarkdownRenderer::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 (seenav-design.md). (5) removed the starter-kitinstrument-sansfont bundling fromvite.config.js; deleted the unused defaultwelcome.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.
- 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.mddotfile — 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.
- Wire a
testkingdom at the fixture path via the test environment / a config override in the test bootstrap. - Tests:
- tree-building lists
README.md+notes/sample.md, and omits the dotfile entries (.obsidian/,.secret.md). GET /test/notes/sample.md→ 200, response contains the rendered<table>and the frontmatter card's values.- Security:
GET /test/../../<something>and URL-encoded..%2fvariants → 404, never escaping the root. - unknown kingdom → 404; missing file → 404; empty kingdom → the empty-state response.
GET /→ redirect to the default kingdom.
- tree-building lists
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-11 — Syntax 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-11 — Running 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-11 — Kingdom path configuration mechanism (hardcoded at MVP). The kingdom map is hardcoded in
config/kingdom.phpfor 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 (à laconfig/filesystems.phpdisks) 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 insideconfig/kingdom.php(theenv()-only-in-config rule), and the Docker packaging item needs an entrypoint that runsphp artisan config:cacheafter 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-17 — Persist 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.mdDecision 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.