The site
Edit on GitHubdocs/ 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, soREADME.mdcan 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.
The remark plugin, and the 140 links the site cannot serve
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.)
| Target | Becomes |
|---|---|
a page inside docs/ | its site URL, from lib/urls.ts |
a path that escapes docs/ | .../blob/main/<path> |
| a folder with a README | that 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_24andpnpm_10. The pnpm MAJOR is pinned deliberately and not taken from thepnpmalias, because the store format changes between majors and the fetcher and the build have to agree on one.fetchPnpmDepsresolvespnpm-lock.yamlonce as a fixed-output derivation. Itssrcis the lockfile and the manifest ALONE, so editing a page does not invalidate the one step of this build that needs the network.pnpmConfigHookunpacks that store and runspnpm 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.
generateBuildIdnow returns a sha256 ofdocs/plus the app, so it changes when the site changes and not when the clock does. - shiki loads a grammar on FIRST USE, and
cppappears 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 languagesdocs/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.2is forced to1.2.2, a patch inside 1.x, reached throughnext > postcss. REMOVE it whennextships a postcss that already pulls 1.2.2 or later (pnpm why source-map-js).sharpis REMOVED from the tree ("-"). It isnext's optional image optimizer, and this site setsimages.unoptimizedbecause 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.devis a CNAME of its own tov1cferr.github.io, DNS-only and not proxied. Explicit because the*.v1cferr.devwildcard 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.
What checks the links this generates
The same split link-checker.md already draws, extended to the links that
exist only after a build:
-
In the gate, offline.
docs-linksrequires a target that leavesdocs/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.
lycheeover the BUILT site, filtered to this repo's own URLs, which it reads out oftools/docs-site/site.jsonrather 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
.txtas prose: thefile://...thatnotes/desktop/desktop-plumbing.mdquotes 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**/*.htmlalso 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 indocs/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.