dotfiles
Repo

The site

Edit on GitHub

docs/ is 92 pages and ~200k words (measured on 23/09/2026), which is a manual that had no reader outside a file tree. This builds it into a static site at https://dotfiles.v1cferr.dev/, and it changes nothing about where a file lives.

The app in tools/docs-site/, the derivation in tools/docs-site/package.nix. Build it with nix build .#docs-site, preview it with pnpm --dir tools/docs-site dev inside the devShell.

Rule 20 is what this page argues for, and the split between the two is the usual one here: the rule states the contract, and everything below is the reasoning, the measurements and what was tried and rejected on the way to it.

It was MkDocs first, and that was the right call then

The site was born on 18/09/2026 on MkDocs with Material for MkDocs, and none of the reasoning that chose it was wrong. It was already in the pinned nixpkgs, so the whole toolchain was a python3.withPackages and nothing landed in a profile. It read docs/*.md and wrote HTML, with no backend and nothing running in production. Starlight and Docusaurus were passed over because both are applications, bringing a Node toolchain and a component model for interactive widgets this site does not have.

What was known on the day, and written down as a bet rather than hidden: MkDocs 1.x had not shipped a release in 24 months, and MkDocs 2.0 is a different project wearing the same name, with the plugin system removed, the config moved to TOML, contributions closed and no license declared. The trigger to migrate lived in ideas.md, and Zensical was the candidate.

The bet was called differently, and this page is the record of it. Zensical was still 0.0.x with a release every two days, which is exactly the churn rule 13 exists to keep out of this repo, and the one piece that would not have carried over was the hook resolving the links to source, which its compatibility page still does not document. So the exit went the other way: to a generator whose upstream is alive, and whose extension point for that hook is a documented API rather than an undocumented one.

The cost of being wrong was small BY CONSTRUCTION, which is the half worth keeping. The tree never moved to match the generator, so what was MkDocs-specific was one config, one hook and one derivation against 92 markdown files. This migration touched exactly those three things.

Why Fumadocs, and what Next.js is doing here

Fumadocs is a documentation framework on Next.js and React. It reads .md and .mdx, and the three things that made it the choice are all extension points the old setup needed and had to reach for a plugin or a hook to get:

  • The URL of a page is a function I write. loader({ slugs, url }) takes both, so README.md can keep meaning "the page of this folder" without renaming a single file.
  • The sidebar is a value I construct. The page tree is a documented data structure, so the hand-written nav carries over as TypeScript instead of YAML.
  • The markdown pipeline is remark, which is where the link rewriting belongs, and it operates on a syntax tree rather than on lines of text.

React is not in the published site as a framework. output: 'export' writes plain HTML, CSS and JS into a directory; there is no Node process in production, and Pages serves files exactly as it did before. Next.js occupies the same slot MkDocs did: a build-time tool, pinned in flake.lock, that never runs where a reader can reach it.

shadcn/ui was passed over, and Motion with it. Fumadocs UI already brings the sidebar, the table of contents, the breadcrumbs, the search dialog, the copy button and the syntax highlighting. Adding a component library on top of that would be maintenance surface bought for nothing, and animation on a page whose job is to be read is worse than nothing. lucide-react comes in as a dependency of Fumadocs UI, which is where the icons already were.

The question came back on 01/10/2026 with a request for a visual layer, and got the same answer for the same reason: decision 0010.

The tree is the durable half, and it did not move

The obvious first instinct is to reorganise docs/ to match the site. That instinct was followed to its end and REJECTED, for three reasons, in order of weight.

A tree mirror was already measured and rejected once. ../README.md records it: 16 of the 51 pages of the day crossed the modules/nixos/ and modules/home/ boundary and 19 referenced two or more modules, because the ARTIFACT crosses. Sections named after the repo's own directories are that mirror wearing a different name, and notes/ is ALREADY grouped by subject.

221 pointers across 157 code files resolve into docs/, guarded by link-checker.md. A nav is a presentation layer that leaves with the generator that rendered it. This migration is the proof: the nav was rewritten and not one of those pointers moved.

Rule 17 says git mv, never delete and create, because the history of a file is the product here. 92 renames survive a --follow, but every one of them puts a step in it.

So the nav is written out by hand, now in tools/docs-site/lib/navigation.ts, pointing at the paths that already exist. It is the MkDocs nav carried over unchanged, entry for entry.

What keeps the nav honest, and where it moved to

A hand-written nav is a SECOND owner of the page list, next to the tables in ../README.md, and rule 14 says that is drift waiting to happen. It is not theoretical: notes/apps/spotify.md had already fallen out of that table before the site existed, and notes/services/libvirt.md lived one day written, indexed and absent from the nav.

tools/docs-site/lib/page-tree.ts is what refuses to build. It walks docs/ and the nav together and fails on a nav entry with no file, a page no entry reaches, two entries landing on one URL, and a page with no H1 to name it. That is the same list validation.nav plus --strict produced, with the messages written here instead of read out of a generator's options.

It runs at the COMMIT now, and that is a measurement and not a taste. The old check was mkdocs build --strict, 7.12s, so it sat at pre-push and a page is added rarely. This one reads docs/ and one TypeScript file on BARE NODE, with no bundler and no node_modules: 0.14s, measured. At fifty times cheaper the right unit is the commit, and the gap that let libvirt through closes by that much more.

tools/docs-site/check.nix is the wrapper the hook runs, and it takes its node from the site derivation's passthru, so the gate and the build cannot end up on different runtimes.

The URLs did not change, and that is checked

A reader's bookmark is not mine to break, so the URL model is exactly the one MkDocs served:

docs/README.md            ->  /
docs/rules.md             ->  /rules/
docs/guides/README.md     ->  /guides/
docs/notes/repo/site.md   ->  /notes/repo/site/

tools/docs-site/lib/urls.ts is the ONE owner of that mapping, and four things read it: the loader that assigns a URL to each page, the plugin that rewrites the links, the nav gate and the export check. A slug rule cannot drift between them because there is only one of it.

Two Next.js settings make it come out that way. trailingSlash: true writes /notes/repo/site/index.html instead of /notes/repo/site.html, which is what Pages serves for a directory URL, and it is also what puts the slash back on every link. And README.md is NOT renamed to index.md: the loader's slugs() treats both names as the folder's own page, so the file GitHub renders as a directory listing is the same file the site renders as a section page.

The URL this code passes around therefore has NO trailing slash, which looks inconsistent with what a reader sees and is not: Fumadocs strips the slash before matching a route against the page tree, so a tree carrying /notes/repo/site/ matches nothing. The symptom was narrow enough to miss, since the sidebar has its own matcher and kept highlighting the right page: the BREADCRUMB went silently empty. Looking at the rendered page in a browser is what found it.

What proves it: tools/docs-site/scripts/finish-export.ts runs after every build and compares the URLs derived from docs/ against the directories actually written to out/, in both directions. A page that stopped being published and a page published from nothing both fail the build. The migration itself was verified by diffing the two site outputs: 92 URLs, identical, plus the /404/ Next.js writes for its own not-found route.

What each page says about itself

MkDocs emitted a description, a canonical link and a sitemap.xml for free, and the first Fumadocs build emitted none of the three. That is worth recording as a MISS rather than as a feature: nothing failed, the pages just quietly stopped being findable, which is rule 16's drift in its most typical shape.

The description is the page's own first paragraph, read by tools/docs-site/lib/summary.ts, the same file and the same reasoning as the title: no frontmatter in docs/, because a description: sitting next to the paragraph that already says it is a second owner of one string (rule 14). It is flattened to plain text, cut at a word boundary at 160 characters, and it feeds three things at once: the meta tag, the Open Graph card and the search result.

The canonical URL carries the trailing slash and the internal one does not, which is the distinction servedUrl() exists for: a canonical that redirects is a canonical a crawler ignores.

sitemap.xml and robots.txt are generated from the same page list the rest of the build uses, with absolute URLs, since a relative loc is one a crawler rejects. There is no og:image, and the Twitter card is summary and not summary_large_image on purpose: there is no image to put in one, and claiming an image that does not exist renders as a broken box.

The build refuses to publish a page that says nothing, which is the half that keeps this from happening twice: finish-export.ts checks every one of the 92 pages for a description and for a canonical pointing at its own URL, and checks that the sitemap lists exactly those pages.

A page points at the module it documents, and of the 452 markdown links in docs/, 140 have targets OUTSIDE docs/ (../../../modules/nixos/services/caddy.nix). The site serves docs/ and nothing else, so each one would be a dead link.

tools/docs-site/plugins/remark-repo-links.ts resolves every relative target against the page's own directory, which is the SAME rule docs-links applies, and the ones that escape docs/ become blob URLs on GitHub. The markdown ON DISK is never touched, so the link keeps working when the file is read there, which is still where most of these pages get read.

It replaces the MkDocs hook that did this and does the same four things, with one improvement. (The hook's own path cannot be quoted here: docs-links would flag a backticked path to a file that no longer exists, which is the check working.)

TargetBecomes
a page inside docs/its site URL, from lib/urls.ts
a path that escapes docs/.../blob/main/<path>
a folder with a READMEthat folder's page
a folder without one.../tree/main/<path>

The repo and the branch are READ from tools/docs-site/site.json, not written in the plugin as well: that would be a second owner of a value whose staleness breaks 140 links at once, which is rule 11. Which branch it is matters, because a blob URL built from a branch that does not hold the path is a 404 for every path. The canary workflow reads the same file to know which URLs are its to check.

A fenced code block is skipped, and now for free. The hook tracked fence openers line by line so an example showing a markdown link would not be rewritten into something the example does not mean. The plugin walks link nodes of the syntax tree, and the text inside a fence is not one, so the class of bug the hook had to defend against cannot occur.

It also marks what the source preview may open, a blob link and a path-shaped code span, and changes no href in doing so. What reads those marks, and why: site-sources.md.

Where it runs: Nix around a Node toolchain

tools/docs-site/package.nix is the same shape it always was, with the interior swapped. Its src is a lib.fileset of exactly docs/ and tools/docs-site/, so a commit that only touches modules/nixos/ does not rebuild the site, and checks.packages builds it, so the site is part of nix flake check with no second definition of "the site builds".

The Node half is three pieces from the pinned nixpkgs:

  • nodejs_24 and pnpm_10. The pnpm MAJOR is pinned deliberately and not taken from the pnpm alias, because the store format changes between majors and the fetcher and the build have to agree on one.
  • fetchPnpmDeps resolves pnpm-lock.yaml once as a fixed-output derivation. Its src is the lockfile and the manifest ALONE, so editing a page does not invalidate the one step of this build that needs the network.
  • pnpmConfigHook unpacks that store and runs pnpm install --offline --frozen-lockfile.

After that the sandbox has no network and never asks for one. NEXT_TELEMETRY_DISABLED turns off the only ping Next.js would still make.

nix build .#docs-site is 28s on this machine with the dependencies already fetched, and the fetch itself is 60s the first time and never again until the lockfile moves.

Reproducible to the byte, and the three things that were not

nix build --rebuild said the derivation was NOT deterministic, which is worth chasing in a repo that pins a dependency universe: an output that differs run to run means the CI and this machine are publishing different bytes from the same commit. Three causes, all found by that one command:

  • Next.js mints a RANDOM build id and stamps it into every page and into the path of the static chunks. generateBuildId now returns a sha256 of docs/ plus the app, so it changes when the site changes and not when the clock does.
  • shiki loads a grammar on FIRST USE, and cpp appears in exactly one fence in these docs: that block came out unhighlighted on some builds and correct on others, a race between parallel workers. The languages docs/ actually uses are preloaded, and the list is read from the fences themselves so it has no second owner to go stale.
  • The loader hands back the pages in the order a parallel build compiled them, and the search index numbers its documents from that order. The index sorts by URL before it is built.

Five --rebuild runs in a row afterwards, byte-identical.

What this cost, stated plainly

A frontend toolchain is more moving parts than python3.withPackages, and pretending otherwise would be the kind of thing rule 16 calls drift. The pnpm-lock.yaml is 495 packages, and the fetched dependency store is 600 MiB, against two Python packages before. pnpm install --force fetches every platform's binaries on purpose, which is most of that.

Three things make the trade acceptable rather than merely survivable. The lockfile is pinned like every other dependency here, so rule 13 holds unchanged. None of it runs in production or in a reader's browser: it is a build-time tool in a sandbox with no network. And docs/ is still 92 markdown files that any generator in this class consumes, so the next exit is the same size as this one was.

The published site fetches NOTHING from a third party

Rule 13 does not stop at my build, it reaches the reader's browser, and a dependency somebody else can move is not less dangerous for running in a visitor's browser than in my sandbox. It is MORE, because I would never see it fail.

finish-export.ts is what enforces it now, instead of an audit somebody remembers to run. It walks every .html and .css in the output and fails the build on any script, link, img, source, iframe, video or audio pointing at an absolute URL, and on any url() or @import in CSS doing the same. A <a href="https://..."> in the prose is content and passes. That closes the hole the old manual audit had: it looked at src= and never saw a font, because a font arrives through href=.

Mermaid ships in the bundle. A ```mermaid fence renders on GitHub natively and on the site through remarkMdxMermaid, which turns the fence into a component, so one source still has two renderers. The library is a pnpm dependency pinned in the lockfile and imported dynamically, so it is downloaded only by a reader who opens a page that draws something. That replaces the vendored tarball and the MkDocs template that loaded it, and for the same reason: Material fetched unpkg.com/mermaid@11, a moving pointer in somebody else's browser.

The lodash-es override is GONE, since 13/10/2026. package.json used to force lodash-es@4.17.23 to 4.18.1: mermaid 12.0.0 depended on chevrotain ~11.1.2, which pinned lodash-es EXACTLY at 4.17.23, carrying GHSA-f23m-r3pf-42rh and GHSA-r5fr-rjxr-66jc (OpenSSF Scorecard found them on 27/09/2026). Mermaid 12.2.0 moved to chevrotain 13, which does not depend on lodash-es at all, so nothing asks for 4.17.23 any more: pnpm why lodash-es lists one version, 4.18.1, with no override in place.

Two overrides remain, since 13/10/2026, both for packages that ride in with next. pnpm audit flagged them after the bump to next 16.4.0, which fixed its own advisories:

  • source-map-js@<1.2.2 is forced to 1.2.2, a patch inside 1.x, reached through next > postcss. REMOVE it when next ships a postcss that already pulls 1.2.2 or later (pnpm why source-map-js).
  • sharp is REMOVED from the tree ("-"). It is next's optional image optimizer, and this site sets images.unoptimized because a static export cannot run it, so the package was dead weight carrying a high advisory. If the site ever serves optimized images, the override goes first.

Both run only at BUILD time, in the sandbox, so neither ever reached a reader; they leave because an audit that is never clean is an audit nobody reads.

TypeScript 7, the native compiler, since 13/10/2026. The 7.x line is the Go port of tsc, and next 16.4.0 type-checks with it during next build with no configuration change. MEASURED in the derivation's log: the type check went from 2.8s on 5.9.3 to 0.92s. It only CHECKS: the JS a reader downloads is compiled by Next's SWC either way, so the published site did not change because of it.

No webfont, for the same reason. Material linked Roboto from fonts.gstatic.com on every page; Fumadocs UI ships no default font at all, so there is nothing to turn off. The stack in tools/docs-site/app/global.css is system-ui for prose and JetBrains Mono for code, neither of them REQUIRED: both sit in front of a fallback chain, so a machine without them loses nothing. That is what docs/assets/stylesheets/fonts.css used to say, and it left with MkDocs.

The search runs in the reader's browser

The index is built into the static output as one JSON file, and the browser downloads it the first time somebody opens the dialog. No endpoint, no backend, no third party, which is the only kind of search Pages can host and the only kind that does not tell somebody else what I look up in my own notes.

It is 8.2 MB raw and 2.1 MB over the wire, measured on 23/09/2026, which is the honest cost of full-text search over 200k words and worth knowing before it surprises somebody on a phone. Three things make it acceptable: it is fetched LAZILY, only when the dialog is opened and not on page load; Pages serves it compressed; and it is cached afterwards.

Algolia and Orama Cloud would each make it a few kilobytes. Both were passed over without much deliberation: they move the index and the queries onto somebody else's server, which is the exact trade the mermaid CDN lost, and a search box that phones home is worse than a slow one.

The three halves Nix does not reach

Deploy is .github/workflows/docs.yml, on every push to nixos: it builds this derivation and hands the result to Pages, which is unchanged by the migration. Three settings live outside the repo, all configured on 18/09/2026, and all three are worth knowing about before debugging a 404:

  • The Pages source is "GitHub Actions", not "Deploy from a branch". The workflow uploads an artifact and the classic branch mode ignores it, so the build goes green and nothing appears.
  • dotfiles.v1cferr.dev is a CNAME of its own to v1cferr.github.io, DNS-only and not proxied. Explicit because the *.v1cferr.dev wildcard points home; DNS-only because GitHub issues the certificate for this name and a proxy in front of it is what breaks that.
  • The router stops swallowing the name. This is the one nobody predicts, and it fails in the worst direction: the site works for the world and not for me.

The split-DNS trap, which cost nothing to fix and everything to find

address=/v1cferr.dev/192.168.1.10 in the router's dnsmasq matches the domain AND every subdomain, so from inside the house the name resolved to this machine and landed on Caddy, which has no vhost for it. Every LAN query is forced through the router, so even asking 1.1.1.1 directly returned the local answer, and the only way to see the real record from here is DoH. The rule itself became address=/*.v1cferr.dev/192.168.1.10 on 09/10/2026, which leaves the apex out of the override: see ../network/network.md.

The fix is four add_list entries forwarding that one name to the DoH proxies, the same shape vpn.v1cferr.dev already uses for the same reason, on server and doh_backup_server both. dnsmasq resolves the LONGEST matching domain, which cesar-ssh.v1cferr.dev already proves in that file. See ../../guides/router-hardening.md for how the device is reached.

docs/CNAME is what claims the custom domain, and it is still the ONE owner of it: the build copies it into the output, and the app reads it from there for the canonical URL rather than writing the domain down a second time.

The same split link-checker.md already draws, extended to the links that exist only after a build:

  • In the gate, offline. docs-links requires a target that leaves docs/ to be git-TRACKED, not merely present, because those two stopped being the same question the day such a link started being published as a blob URL. It reads the site's own TypeScript too, since the headers there carry the same pointers every other module's do.

  • In the canary, weekly. lychee over the BUILT site, filtered to this repo's own URLs, which it reads out of tools/docs-site/site.json rather than repeating. The markdown run cannot see one of these links, since none of them appears in any .md. 186 of them now, up from 91, because every page carries its own "Edit on GitHub".

    It reads the HTML and nothing else, and that is a trap this migration walked into. Next.js writes a plain-text RSC payload next to every page, and lychee reads a .txt as prose: the file://... that notes/desktop/desktop-plumbing.md quotes inside backticks is a code span in the HTML and a bare URL in the payload, so the weekly run started failing on a link that does not exist. Scoping it to **/*.html also cut the run from 32s to 12s, since the payload is the same content a second time.

oxlint is the JS half of the gate, over tools/docs-site/. Its binary comes from nixpkgs like every other linter here, so the lock pins it (rule 13) and nothing enters package.json for it; the rules live in tools/docs-site/.oxlintrc.json, which is the file the editor reads too.

No frontmatter, and the title is the H1

Fumadocs wants a title for every page, and the obvious answer is a --- block at the top of all 92 files. That was REJECTED: a title: next to an # H1 is two owners of one string (rule 14), and the drift it invites is a page whose tab says one thing and whose heading says another.

tools/docs-site/lib/summary.ts reads the first H1 instead, and the collection's schema is handed the raw source before the markdown is compiled, which is where that can happen. A page with no H1 fails the build rather than getting a made-up name. The H1 is then taken OUT of the rendered body, since the layout renders the title itself, and docs/ gains not one byte.

The visual layer, and what it was built from (01/10/2026)

Diagrams, steps, icons and an overview on the home, with not one package added: why that and not a component library is decision 0010. What is worth knowing about each piece is where it hides a trap.

A diagram takes its colors from the preset, at render time. Mermaid's dark and default themes ignore the page around them, so a diagram read as a pasted screenshot. The base theme lets every color be set, and components/mermaid.tsx reads each one off a Fumadocs token through a probe element, which the browser resolves to rgb(): a form mermaid's color parser accepts, which the hsl() text of the token is not guaranteed to be. So the diagrams own no palette, and a change of preset recolors them. Mermaid's radius misses some shapes, and global.css rounds those.

The site wears the desktop's palette, since 13/10/2026. The hexes come from modules/home/desktop/palette-presets.nix, the same data file palette.nix reads, so the site owns no color of its own (rule 11). The derivation writes the DEFAULT preset into palette.json, and pnpm palette writes the same file in a dev tree (predev runs it); it is gitignored, since it is an output. lib/palette.ts maps palette colors onto Fumadocs' roles and the layout writes them into the HTML as a <style>, so the theme costs no request and does not flash. Dark mode IS the palette; light mode keeps the neutral preset and takes only the accent, darkened in oklab, because no preset carries a light variant and inventing one would be a second palette. The code blocks' dark theme is shiki's official theme for the same preset (tokyo-night today), so a fence reads like the editor. A host's my.theme.name does not reach the site: it is public and built in CI, so it wears the default.

A diagram opens full screen, with zoom and pan, since 13/10/2026. A wide flowchart scaled into the text column was drawn at a third of its size and unreadable, so components/diagram-viewer.tsx wraps every diagram: a click on it, or its expand button, opens a native <dialog> that fits the SVG to the screen, zooms with the wheel around the cursor, pans by dragging, and downloads the SVG. It is pointer events and a CSS transform, so nothing joined the lockfile (decision 0010 holds), and the copy of the SVG exists only while the dialog is open, so a page still draws each diagram once. The dialog is named after the diagram's accTitle, which every diagram here carries anyway.

/graph is the map of the whole tree, since 13/10/2026. lib/graph.ts reads every page and its relative links at BUILD time, the same resolution the link rewriter applies, and colors each page by its top-level nav section with the palette's hues. Hover lights a page's neighbors, a click opens it, the search flies to a page, and a section chip isolates one section. It is the one dependency the visual layer took (decision 0013), loaded by that page alone. /graph is a route of the app and not a page of docs/, so finish-export.ts lists it next to the 404 as a URL with no page behind it.

A section has ONE hue, wherever it is drawn. lib/sections.ts gives each top-level nav section a palette hue in the sidebar's order, and both the graph's dots and the home's card icons read it, so the two pages read as one system. The home also names its author, from site.json, the file that already owns the repo's identity.

Steps are a GUIDE's numbered headings, and only a guide's. Fumadocs' remarkSteps turns any ### 1. Title into a step, and run over everything it would have turned the 23 cards of rules.md into a procedure, where the number is API and not an order. remarkGuideSteps in source.config.ts runs it under guides/ only, and it runs LAST, after the plugin that names the anchors: the number is still in the heading at that point, so the anchor and the TOC entry are #1-boot-the-installer-..., the same ones GitHub gives the same heading. Run first, it would strip the number before the anchor is named (reasoned from its source, not tried), which is a reader's deep link broken by a renderer.

An icon is DATA in the nav. lib/navigation.ts names a group's icon as a string, because lib/page-tree.ts reads that file on bare node with no node_modules, where React cannot be imported. components/section-icon.tsx draws the name, and its Record over the union makes a name with no icon a type error and not a blank square.

The home owns no fact. The hero is site.json, the cards are the page tree (a group's icon, its page count, its first page names), and an entry point to a page that is gone FAILS the build instead of publishing a dead button. The tree is built once, in lib/tree.ts, so the sidebar and the cards cannot disagree.

A diagram is a fence in docs/, never JSX, so GitHub draws it too, and each one carries an accTitle and an accDescr, which mermaid turns into the SVG's title and description for a screen reader. A diagram states what the page around it already says, and points at nothing the page does not: the topology in network is read off the router's mirror and dated.

What it cost, MEASURED on 01/10/2026 against the build before it: the JS every page loads up front went from 370.6 to 370.9 KiB gzipped, and a page's HTML grew by about 2.3 KB gzipped, which is the ten sidebar icons, written once into the HTML and once into the RSC payload. Mermaid is still fetched only by a page that draws.

A local pnpm build does not exit. Measured on 01/10/2026: next build wrote the whole export and then sat idle until killed, so finish-export.ts never ran. The cause was not found. nix build .#docs-site finishes in about 30s, and it is the build that publishes, so it is the one to trust; pnpm dev works for previewing.

What is still worth doing

  • The generator is REPLACEABLE, and keeping it that way is the point. If Fumadocs ever goes the way MkDocs did, what has to be rewritten is tools/docs-site/, against 92 markdown files that did not move. Nothing in docs/ knows which generator renders it.
  • The 2.1 MB search index is the one number I would like to see smaller without giving the index to somebody else. Nothing is planned, and measuring it again after the docs grow is the trigger.

The long form of rule 20

Moved here VERBATIM from rules.md on 30/09/2026, when rule 20 became a card. Nothing was cut; the card links back here.

THE DOCUMENTATION IS A PUBLISHED PRODUCT, AND THE TREE IS NOT THE NAV: docs/ is built into a static site at https://dotfiles.v1cferr.dev/ by tools/docs-site/package.nix, and that derivation lives in packages, so checks.packages builds it and the site is part of nix flake check: ONE definition of "the docs build", read by the gate, the pre-commit hook, the CI and the deploy, which is rule 14 applied to a renderer. THE RULE IS THE SEPARATION. The FILE TREE answers "where does this page live" and is split by FUNCTION at the top (the rules, the open items, the history, the notes, the guides, the ideas) and by SUBJECT inside notes/, documented in README.md and notes/README.md. The NAV answers "how does a reader walk this" and lives in tools/docs-site/lib/navigation.ts, written out by hand. THE TREE NEVER MOVES TO MATCH A RENDERER, and the temptation is real, because regrouping docs/ by topic to mirror a sidebar looks like tidying. It is not, for three reasons in order of weight: sections named after the repo's own directories are the tree MIRROR that notes/README.md already measured and rejected (16 of the 51 pages of the day crossed the modules/nixos/ and modules/home/ boundary and 19 referenced two or more modules, because the ARTIFACT crosses); 221 pointers across 157 code files resolve INTO docs/ and rule 2 made that pointer the only path from a module to its reasoning; and rule 17 wants a git mv history that survives, which 92 renames put a step into for a sidebar the nav expresses for free. A nav leaves with the generator that rendered it and the tree outlives it, so coupling the durable half to the disposable one is backwards, which is the whole of this rule in one sentence. WHAT ENFORCES IT IS THE BUILD AND NOT A NEW CHECKER, the same trade rule 7 makes for shell scripts: the site REFUSES to build when a page is left out of the nav, or a nav entry has no file, or two entries land on one URL, and it earned its keep on the first run by catching notes/apps/spotify.md, written and indexed nowhere. IT RUNS AT THE COMMIT AND IN THE GATE, and that placement is a measurement rather than a taste: under MkDocs it was a 7.12s --strict build, so it sat at pre-push, and since the Fumadocs migration it is a read of docs/ plus one file on bare node, 0.14s measured, so the cheap unit is the right one. The gap was real for exactly one day: notes/services/libvirt.md was written, indexed in notes/README.md and left out of the nav, and nothing said so until the gate, two commits later. And EXISTING STOPPED BEING THE WHOLE TEST for a link: a target that leaves docs/ is published as a GitHub blob URL, so it must be git-TRACKED and not merely present on disk, which docs-links now checks. RULE 13 DOES NOT STOP AT MY BUILD, it reaches the reader's browser: the published site fetches NOTHING from a third party, and the theme's defaults broke that twice on the day the site was born. Mermaid arrived from unpkg.com/mermaid@11, a moving pointer, and Roboto from fonts.gstatic.com on every page, which is worse for being invisible: an audit that greps src= never sees a font, because a font arrives through href=. Both are gone, and since the Fumadocs migration the BUILD is what refuses to publish a page reaching for a third party, instead of an audit somebody remembers to run. The reasoning is that a dependency somebody else can move is not less dangerous for running in a visitor's browser than in my sandbox. It is MORE, because I would never see it fail. AND THE GENERATOR IS REPLACEABLE ON PURPOSE, which stopped being a claim on 23/09/2026: MkDocs was frozen upstream, the bet was recorded with a trigger, and calling it cost one directory. What is generator-specific is tools/docs-site/ and one derivation, against 92 markdown files that any generator in this class consumes and that did not move by one byte. That is the same reason the tree did not move in the first place: the markdown is the asset, everything around it is scaffolding. The detail, the measurements and what was rejected: notes/repo/site.md.