Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

What a documentation router serves

[Plain Text](./plain-text) is dead in the tree and alive on the site. The file is plain-text.md; the router elides the extension, and starship’s own preset page links it both ways in one paragraph, once with the extension and once without. A checker that reads only the tree calls the second one broken. It is not broken, and 247 of the 516 missing references in the scan ledger’s rescan are that same shape: a target the tree holds under a spelling the router maps.

So the resolver asks the same question the router does. A destination the tree holds is its own answer. A destination the tree does not hold is looked up again under the spellings a modelled router serves, and the first one that names a file resolves the reference to that file. Resolution places this in the order; this page holds the spellings and where they came from.

The three spellings

SpellingA destination likeReachesServed by
extensionlessguideguide.md, guide.mdx, guide.markdownvitepress
output-extensionguide.htmlguide.mdmdbook, vitepress
readme-indexdir/index.md, dir/index.htmldir/README.mdmdbook, vitepress when configured for it

A spelling only ever names a file that is already in the tree, so it can widen what resolves and can never invent a target. Everything a spelling does not reach stays exactly as missing as it was, under the destination the author wrote.

The elided extension is tried under each suffix a page source carries, .md first, then .mdx, then .markdown. A router serving a page at a clean URL serves an MDX page there the same way, so reading guide as guide.md alone leaves [items](./item) missing in a tree whose page is item.mdx. Ionic’s documentation writes that link in seven pages and reaches item.mdx from every one, 24 references counted once per versioned copy. A destination already carrying one of those suffixes names a source file rather than a route, so page.mdx is never looked up as page.mdx.md.

Two spellings are never tried. A destination ending in / promised a directory, and the tree answers a directory itself. A same-repository forge URL is read by the forge, which serves the tree rather than a site, so blob/main/docs/guide stays missing even though docs/guide.md exists.

Where the spellings came from

The published expectations are in route-spelling vectors, harvested 2026-07-26. One probe tree of five pages, one destination per probe page so every verdict names its own case, and each router asked in its own voice rather than read from its documentation:

RouterVersion and configurationHow it answered
mdbook0.5.4, defaultthe href it emitted, resolved against the built output tree
vitepress1.6.4, cleanUrlsits own dead-link report, corroborated by the output tree
vitepress-readmethe same, plus the README-to-index rewrites starship configuresthe same
mkdocs1.6.1, default use_directory_urlsits unrecognized-link warnings

mkdocs serves none of the three. It demands the source path and warns otherwise, which is why a repository it publishes gains nothing here and loses nothing: ruff’s 102 missing references did not move by one. What it does move is the directory a destination is read from, and that is the directory-url rule below.

The vectors also keep a verdict this table does not model. mdbook rewrites a link to dir/README.md into dir/README.html while writing that page to dir/index.html, so its own source spelling is the single form it fails to serve. The tree holds that file, and a file the tree holds resolves without any rule being asked.

What a generator in the tree anchors

The three spellings hold in every tree, since guide reaching guide.md costs one 404 on a site with no router and nothing more. A second set holds only where the tree carries the generator’s own configuration file, because each of these moves a destination to another directory and would be a guess anywhere else. A rule turns on when one of its files is a blob on the document’s ancestor chain, nearest directory first. Presence is what selects a rule. Two of them then read one value out of the file that selected them, the Antora component name and the Sphinx source suffix, and the rest never open it.

Which sites a tree declares is a question about the whole tree, and which site owns a document is a question about that document. Where nothing on the chain declares a generator and the tree declares it in exactly one directory, that directory owns the document, because a site in website/ reading ../docs leaves every page it publishes off its own chain. A tree that declares the same generator in two places fixes no owner, so a document outside both keeps the chain’s answer. Five rules stay on the chain whatever the tree holds: astro, eleventy, hugo, jekyll and mdbook-pages answer for a page the build serves rather than for a file, so turning one on withholds an answer instead of finding one, and so does the partial rule further down. A configuration under a tree the scan already excludes declares nothing, which is how sphinx’s own repository carries 176 conf.py and declares one site.

RouterSelected byServes
antoraantora.ymlantora-resource
docusaurusdocusaurus.config.ts, docusaurus.config.mts, docusaurus.config.cts, docusaurus.config.js, docusaurus.config.mjs, docusaurus.config.cjssite-alias, content-root, document-id
mkdocsmkdocs.yml, mkdocs.yaml, zensical.tomldirectory-url
sphinxconf.pysource-root
mdbook-pagesbook.tomlbook-route, built-page
zolaconfig.tomlcontent-root
astroastro.config.ts, astro.config.mts, astro.config.js, astro.config.mjs, astro.config.cjsbuilt-route
nextjs-appnext.config.js, next.config.mjs, next.config.ts, next.config.cjs, next.config.mtspage-directory
eleventyeleventy.config.ts, eleventy.config.js, eleventy.config.mjs, eleventy.config.cjs, .eleventy.jsbuilt-route
hugohugo.toml, hugo.yaml, hugo.json, config.toml, config.yaml, config.json, config/_default/hugo.toml, config/_default/hugo.yaml, config/_default/hugo.json, config/_default/config.toml, config/_default/config.yaml, config/_default/config.jsonbuilt-route
jekyll_config.ymlbuilt-route

antora-resource reads an AsciiDoc destination as an Antora resource ID, [version@][component:][module:][family$]relative, in a document under modules/<name>/ of the component whose root holds antora.yml. The relative part is anchored at the family directory of the named module, or of the document’s own module when none is named. So xref:index.adoc[] in modules/api/nav.adoc is modules/api/pages/index.adoc, include::partial$success.adoc[] in a page of that module is modules/api/partials/success.adoc, xref:cli:index.adoc[] is modules/cli/pages/index.adoc, and image::diagram.png[] is modules/api/images/diagram.png. The families are page, partial, example, attachment and image. An xref defaults to the page family and an image to the image family. An include without a family coordinate stays relative to the file that includes it, which is Antora’s own compatibility rule for the plain include. This rule replaces the relative reading rather than following it: a page under pages/sub/ that writes xref:index.adoc[] means the family root, and Antora never looks beside the file. A version or component coordinate is answered from the source roots this tree holds for that component version: xref:1.0@guide.adoc[] under a descriptor saying version: '1.0' is the component’s own guide.adoc, and xref:other::page.adoc[] is a page of the ROOT module of a component declared elsewhere in the tree, in whatever version the tree holds, since a coordinate without a version means the latest one. A coordinate no root in the tree answers, another version or a component whose descriptor lives in another repository, names a catalogue this engine does not build, so it is unsupported-reference-semantics with the external-inventory reason rather than a path or a URL. A ./ or ../ relative is Antora’s own page-relative form and stays beside the document.

A component is not one directory. Antora assembles it from every source root whose antora.yml spells the same name, so a module coordinate is answered by whichever of those roots holds the resource. Spring Boot has four roots and all four declare name: boot, which is why xref:gradle-plugin:packaging-oci-image.adoc written under documentation/ means a page stored under build-plugin/. The name and the version are what is read out of the descriptor, each a plain scalar on a line of its own, and a root naming another component or version answers nothing for a coordinate that did not name it. The document’s own root is asked first, so a finding still names the path under the root the author wrote in.

The descriptor also says when the tree is not the whole component. The ext block is what Antora hands to the extensions that assemble a component while the site is built, and this engine runs none of them, so a component reserving that block holds resources no tree walk can enumerate. All four of Spring Boot’s descriptors reserve it, and its build zips generated pages and partials into modules/appendix/partials/configuration-properties, modules/api/partials/rest/actuator and eleven more directories before Antora reads any of them. A resource ID such a component does not answer is unsupported-reference-semantics with the unmodelled-route reason rather than a missing file. Where no descriptor reserves the block, as in Asciidoctor’s own documentation, every claim stands.

site-alias and content-root are the order Docusaurus’s resolveMarkdownLink tries directories in, read from packages/docusaurus-utils/src/markdownLinks.ts, for a document the site owns: one under the directory holding docusaurus.config.ts or its .mts, .cts, .js, .mjs and .cjs spellings, the list the site loader tries, and every document in a tree holding one such file and no other. @site/blog/img/output.png is blog/img/output.png under that directory, for a link and for an image. A bare .md or .mdx destination, one starting with neither ./, ../ nor /, is tried beside the document first, then under the plugin content path the document sits in, then under the site directory. On a translated page under i18n/<locale>/docusaurus-plugin-content-docs/ that content path is the locale’s copy and then the plugin’s own, which is how a page the locale has not translated is still reached. A .md or .mdx destination opening with / is tried under the same content paths and the site directory, never beside the document, rather than read as a URL, and an image opening with / reaches the file the static directory beside the configuration holds. Both hold for a page under one of the default content paths, since a plugin path or static directory a configuration adds is not read here; anywhere else they stay site routes. [static folder](static-assets.mdx) in docs/api/themes/configuration.mdx reaches docs/static-assets.mdx that way. The content paths read are the plugin defaults, docs, blog, src/pages and versioned_docs/<version>, under the site directory and, for a document outside it, under the directory holding the site, which is where a site in website/ finds the ../docs its configuration names. So [View](view.md) in react-native’s docs/legacy/direct-manipulation.md reaches docs/view.md, the file the archived copy of that same page under website/versioned_docs/ has always reached. A plugin configured to read another directory gets the site-directory step alone, and a localized tree under i18n/ is not read. A ./ or ../ destination is beside the document and nowhere else, which is Docusaurus’s rule too.

Where no docusaurus.config.* sits above the document and the tree holds several elsewhere, @site/ is a destination this run cannot answer rather than a missing directory called @site: the alias is expanded when the site is built, and with more than one site in the tree nothing here says which directory it names. That is unsupported-reference-semantics, the answer an AsciiDoc {attribute} already gets for the same reason. Only the opening is read, so a tree with a real @internal/ directory resolves that path as written.

document-id is the identity a document declares for itself. Docusaurus publishes a page at the id in its frontmatter rather than at its file name, so jest’s docs/CLI.md opens with id: cli, the site serves it at cli, and every link to it writes cli, which names no file in the tree. A slug overrides the id, read from the document’s own directory or, with a leading slash, from the content root it sits in, and a document declaring neither is published at its own path without the source extension, which is how a bare notes reaches notes.mdx. The routes are collected once per snapshot, from the documents it already holds, and a destination the tree does not answer is looked up in that collection after the three spellings, so a real file always wins. A route two documents claim is left out rather than decided between them.

This rule asks the document’s own ancestor chain nothing at all. A site names its content directories in the configuration file, which this engine never opens, and jest’s site sits in website/ while the documents it publishes sit in ../docs, so selecting on the chain would miss every one of them. Any docusaurus.config.* in the tree turns the collection on, and a tree with none has no collection at all. Inside a document the rule reads two frontmatter keys written on lines of their own; the region stays opaque to the grammar, and a value that is not a plain scalar is declined rather than guessed at. A page that declares a site-absolute slug also moves its own URL, and a destination relative to that URL is not read.

A document under one of those content paths is not always a page. Docusaurus excludes every name opening with _ from routing, a directory as well as a file, so docs/api/plugins/_partial-tags-file-api-ref-section.mdx in its own repository is served at no URL at all and is rendered into the two pages that import it, plugin-content-blog.mdx and plugin-content-docs.mdx.

RouterPublishes no page forUnder
docusaurusa name opening with _docs, blog, src/pages, versioned_docs/*

A fragment written inside such a document names an identity of the page that renders it, and one partial may be rendered into several, so the tree does not say which page that is. The identities the file writes itself still answer, and a fragment nothing in it publishes is undecided rather than absent, the same answer Resolution gives any target whose identity set is incomplete. That is the whole of it: a path in the same document is read from the file as written, since that is where Docusaurus reads one from wherever the page ends up, and a path the tree lacks is still missing. The three links in that one partial are 24 of the 70 missing targets a docusaurus clone reported, counted once per versioned copy, and none of them is broken: that site sets onBrokenAnchors: 'throw' for the default locale, so an anchor that really was absent would have failed its own build.

The name decides this rather than the import. An import says one document renders another, which a page published at its own URL may also be, and the walk that finds one is bounded by references-per-document and parser-nesting, so an edge nobody walked is not evidence that a file is a page. The exclusion is Docusaurus’s own, read from the name alone, and a tree that declares no site keeps every anchor claim it had.

directory-url reads a raw HTML destination in a document under a mkdocs.yml the way the browser does. mkdocs rewrites the destination of a Markdown link and leaves an <a href> or an <img src> written by hand alone, so that one is resolved against the URL the page is served at rather than against the source file. A page is published at a directory of its own name, or at its own directory when the source is that directory’s index.md or README.md. So <a href="getting-started/"> in docs/index.md reaches docs/getting-started.md, and <img src="../../img/light.png"> in docs/user-guide/choosing-your-theme.md, published at user-guide/choosing-your-theme/, reaches docs/img/light.png. A trailing slash names a page here rather than a tree, since every page URL ends in one, so the destination is asked without it and the three spellings answer. The document’s own directory is tried after the published one, so a raw destination that already reached a file beside the source still reaches it.

source-root is Sphinx’s :doc: role with a leading slash, in a document under the directory holding conf.py. :doc:`/testing` in docs/tutorial/deploy.rst is docs/testing.rst: the docname under the source directory, with a suffix that root reads. A docname is a file name without its suffix, so the dot in /releases/1.1 is part of the name and that target is docs/releases/1.1.rst, while a trailing slash is normalized away before the name is looked up, the way docname_join does it, so :doc:`</ref/applications/>` is docs/ref/applications.rst. A plain hyperlink with a leading slash is still a site route, since Sphinx emits it as written. A relative one naming a page’s .html under the same conf.py reaches the source Sphinx built that page from, under each suffix the root reads, since the build writes every page beside where its source sits. A :doc: target in a document with no conf.py above it is answered by the one source directory the tree declares, and stays the declared site route it was where the tree declares several.

Which suffixes that root reads come out of conf.py. A project that writes its pages in another suffix says so in source_suffix, and Django writes all 677 of its pages in .txt. Two forms are read, both on one line with the key opening it, which is where a Python assignment binds a name at the top level: source_suffix = ".txt" is one suffix, and source_suffix = {".txt": "restructuredtext"} is the mapping form, whose value has to name the reStructuredText parser, since {".md": "markdown"} says the opposite of what this rule wants. A commented-out line, an add_source_suffix call, a mapping left open across lines, and a list naming no parser are all declined, and a root that declares nothing this reader spells out plainly reads .rst as before. A root naming several is read under each, since Sphinx finds a docname’s file under whichever suffix it carries, and a root that loads myst_parser or myst_nb reads .md as well. So :doc:`/ref/models/querysets` in Django’s tree is docs/ref/models/querysets.txt, and the same declaration is what makes those files documents at all, which Discovery states.

A relative :doc: target and a toctree entry take the same suffixes. The adapter keeps the docname as the author wrote it, and resolution adds each suffix of the root the document sits under, or its own format’s suffix when no conf.py sits above it, so Django’s relative toctree entries reach its .txt pages.

Each of the spellings above widens what resolves and nothing else, like the three: an anchored destination is looked up in the tree and is missing when the tree does not hold it, so xref:load-templates.adoc[] in modules/api/pages/index.adoc is still missing while modules/api/pages/load-templates.adoc is not there. One thing does move: the intent. A routed guide keeps guide as the path the author meant, while an Antora xref’s intent is the family path and a Sphinx :doc: target’s is the docname under conf.py, because the author never meant a sibling file.

Where a rule keeps the reading from the document’s own directory, that reading is the intent and the finding names it. A Docusaurus bare path keeps the sibling, since Docusaurus tries the sibling first, and a raw <img src="../../img/light.png"> under mkdocs keeps img/light.png, the path a forge asks for, while the published directory still answers it with docs/img/light.png. The published directory is a URL rather than a place in the tree, so naming it in a finding puts a directory nobody wrote into the report: a destination under docs/en/docs/fastapi-people.md that reached nothing used to be reported as docs/en/docs/fastapi-people/{{ sponsor.url }}, and neither fastapi-people/ nor the rest of it is in that file.

One opening is not a path under any rule. A bundler’s inline request syntax reserves it for the loaders the request disables, so [assets](!file-loader!./asset.pdf) names a loader chain ending in a resource rather than a file. That is an invalid-reference and not a missing path with a loader name inside it. Only the opening is read, so a name carrying the character anywhere else is an ordinary path.

BundlerInline request opens with
webpack!, -!

The two forms webpack documents for its own loaders are both here: ! disables the configured normal loaders and -! the pre-loaders, and the !! that disables every loader opens with the first of them.

An expression the build fills in is not a path either. <a href="{{ sponsor.url }}"> in fastapi’s docs/en/docs/fastapi-people.md names a sponsor’s site once the page is generated and names nothing at all in the tree, so it is unsupported-reference-semantics with the attribute-dependent reason rather than a missing file called {{ sponsor.url }}. That is 39 of the 79 missing targets a fastapi clone reported.

Expression opens withand closes with
{{}}
{%%}

The opening is what decides, and the closer need not be there at all. A destination ends where the grammar holding it says, and that cuts an expression in half whenever the expression quotes something of its own. HTML ends an attribute value at the next quote, or at the first space when the value is unquoted, so <a href="{{< ref "#LifecycleHandler" >}}"> hands the resolver {{< ref and <a href={{ ref . "x" }}> hands it {{. Read as a relative path, each claims a file nobody wrote under a name nobody spelled. Kubernetes’ website tree writes the first shape 2,774 times.

A name carrying one brace is still an ordinary path and resolves as written, and so is one carrying a closer alone. Jinja, Liquid, Nunjucks and Handlebars all spell an expression this way, which is why the rows are the delimiters rather than the name of one generator. A tree that holds a real file whose name spells an expression, as a cookiecutter template does, gets this answer for it instead of the file, and so does one whose file opens an expression it never closes.

What the build answers instead of the tree

A generator that does not put its pages where its sources are breaks the assumption behind every rule above, that a relative destination names a place in the tree. Hugo, Jekyll, Eleventy and Astro each resolve one against the URL the page is served at, and that URL comes out of a configuration this engine does not open. Twelve ordinary projects put a number on it: of 1,596 missing targets, the 1,149 from repositories built on a generator with no rule here held 41 real breaks. So built-route answers such a destination with unsupported-reference-semantics and a reason saying the route model is not modelled, the way an absolute site route already does, instead of naming a file nobody wrote.

A page’s own source is out of that reach. A build reads foo.md and publishes what it made from it under a URL of its own, so no URL it serves is called foo.md, and no route model is needed to say the destination reaches nothing. built-route therefore reads the destination’s own suffix, the three that extensionless appends, and leaves the tree’s answer standing. It is the other end of the pair built-page reads: .html is what a build serves, and .md, .mdx and .markdown are what it consumed. An asset keeps the boundary, because its own prefix still moves: Jekyll’s docs write <img src="../../img/jekylllayoutconcept.png"> from a page served at /tutorials/convert-site-to-jekyll/, the climb lands on the site root rather than on the repository root, and docs/img/jekylllayoutconcept.png is there.

Sixteen destinations across a 56-repository corpus are that shape, out of the 1,523 the boundary covered, and they carry twelve findings. Eleven are breaks. Kubernetes’ website goes from 89 missing targets to 97: six translated READMEs link a code-of-conduct.md that only the repository root holds, and one Korean page copied two English links without their leading slash. Jekyll gains the docs/pages/team.md its docs/_docs/security.md writes, which that repository already reports from the copy of the same sentence under .github/. The eleventy site gains two siblings written with a directory too many, and the twelfth finding is a page of it showing <a href="my-template.md"> as an example of markup rather than as a link. hugoDocs, the rust book and spring-boot do not move, since no destination behind their boundary names a source.

The selection is the same as every rule above: the file has to sit on the document’s ancestor chain, and only the path side moves. An anchor into a document the tree holds is still read, because the identities a document publishes are enumerable whatever a site does with its URLs, which is why helix keeps all nine of its anchor findings and its one missing path. A tree carrying none of these files keeps every claim it had, which is how the Kubernetes community repository keeps all 254 of its missing targets.

mdBook is modelled rather than declared, because its URLs are in the tree. A book’s root is the directory holding book.toml, its pages are the Markdown under the source directory the [book] table names in src, src when it names none, and each page is served at its path under that directory, so second/src/ch01.md is second/ch01.html. A src climbing out of the repository, or one this reader cannot spell, leaves the book unread rather than read under the default. A destination climbing past the root is read back under the source directory of whichever book holds the page it names, and that is book-route: the rust book’s second-edition/src/ch09-02-recoverable-errors-with-result.md writes ../ch09-02-recoverable-errors-with-result.html, means the current edition’s copy, and 241 of that repository’s 403 missing targets are that one shape.

built-page is the rest of the same book. A destination ending in .html that no book source answers names a page of the built site rather than a file. The rust book’s redirects/ tree writes 79 of those and its chapters write 33 more, climbing to std, reference and nomicon on the same domain. None of them is a break and none is placeable, so all take the boundary, and what stays missing is what always was: 28 images under nostarch/ and two paths in a crate README.

Zola is modelled through the one prefix it spells. @/ opens a path from the content directory beside config.toml, so @/documentation/page.md in Zola’s own repository is docs/content/documentation/page.md, and 59 of its 80 missing targets resolve that way while the other 21 stay the dangling theme files they are.

A config.toml may be Zola’s or Hugo’s, and a config.yaml or config.json may be Hugo’s or any other tool’s, so the name says nothing and the file’s own bindings decide. Zola requires base_url and Hugo reads baseURL in any case, so a file binding the first is Zola’s and one binding the second is Hugo’s. Hugo lets baseURL go unset, so a file binding neither is Hugo’s too when it binds a camel-case key only Hugo reads, languageCode, defaultContentLanguage, contentDir, publishDir, enableRobotsTXT or enableGitInfo, where Zola writes snake case. A Rust workspace’s .cargo/config.toml binds neither, so it configures nothing and ripgrep, bat and helix keep every claim they had. Hugo’s rule withholds answers rather than adding them, so under a shared name it also needs the content directory the file names, content where it names none, to sit beside it, the way the Zola reading needs its own. A file under config/_default is Hugo’s by where it sits, and it declares the project two directories above it.

Those older spellings are common. Of eight Hugo sites checked on 2026-09-22 only three used hugo.toml or hugo.yaml: etcd.io and grpc.io configure in config.yaml, the CNCF glossary and containerd.io in config.toml, and opentelemetry.io in config/_default. Read as the Hugo sites they are, those five go from 3,373 claims to 21, most of them relative links that work because Hugo serves every page at a directory of its own, and etcd’s 945 fall to none.

Jekyll’s permalink template is what this leaves undone. docs/_config.yml in Jekyll’s own repository sets permalink: "/:collection/:path/", every page URL then ends in a slash, and a ../ climbs one level less than the file path does, which is 19 of that repository’s 24 missing targets. Reading the template means opening the configuration file, and no rule here opens one, so the boundary answers the whole docs/ tree instead and those 19 go with it. Three of the four real breaks sit in .github/, outside the tree that file declares, and the fourth is the page source docs/_docs/security.md names.

What a build’s own configuration says

A declaration says two things: which directory holds the pages, and what path the site is served at. A Hugo configuration already says both, so a repository keeping one is not asked to write them again. contentDir names the directory, read against the directory the configuration sits in, and the path of baseURL is what every route opens with. Where the file names no directory, Hugo reads content and so does this.

A language table names a root of its own. [languages.fr] puts its pages under the contentDir it binds and serves them under fr, below whatever the site is rooted at, unless it is the language the file names as its default and that default is not itself in a subdirectory. So a route without a code is the default language’s, and one with a code is that language’s.

Hugo serves every page URL in lowercase unless the configuration binds disablePathToLower true, so a route is read that way under a Hugo root: /functions/cast/tofloat/ reaches functions/cast/ToFloat.md. A section’s route reaches the _index.md that writes it and a bundle’s the index.md. Read whole on 2026-09-25 under the observe profile, Hugo’s own documentation resolves 3,762 references where it resolved 3,209 and declines 38 where it declined 591. The Kubernetes website resolves 27,630 where it resolved 27,063, and six claims arrive, each an anchor on its concepts index page, which publishes no heading of that name.

An Astro configuration that imports @astrojs/starlight says both as well. Starlight’s loader reads the pages under src/content/docs beside the configuration, and the quoted base the file binds is what every route opens with, the site root where it binds none. Astro slugs every path in lowercase, so a route is read that way here too. A page is served at the slug its frontmatter declares in place of its own path, read from that directory, and a directory’s index page is served at the directory. Starlight serves a page nowhere else, so a route no page claims stays with the build, which is where its fallback serves the default locale’s page for a translation that lacks one. A srcDir moving the sources, or a base spelled as anything but a quoted literal, leaves the site unread. Read whole on 2026-09-25 under the observe profile, Astro’s documentation resolves 12,106 references where it resolved 3,471 and Starlight’s own resolves 2,078 where it resolved 656. Three claims arrive, each an anchor into a translated page naming a heading that page does not carry.

A configuration that mounts its content rather than naming a directory says the same thing another way, and the mount targeting the content directory is read where no key names one. Several such mounts are a site composed of parts this does not take apart, so none of them names a root then. Hugo’s own documentation is the case: one mount reads its pages from content/en, and reading it takes that repository from 1,421 declined routes to 553.

A root belongs to its project rather than to the pages beneath it, since one page of a site routes to another wherever either sits. That is what lets a Japanese page on the Kubernetes website reach the English page its route names. Read whole on 2026-09-21 under the observe profile, its site routes fall from 28,075 to 4,272 and resolved references rise from 5,073 to 27,063, with no missing path arriving. Sixty claims arrive, every one an anchor on a page that only resolves now. Most name a heading the page does not carry, and the rest divide between headings written as a shortcode and translated pages whose prose kept the English anchor while the heading above it was translated, which are breaks a translation workflow tolerates.

A comparison reads this configuration the way it reads a declaration, with one difference. A site the candidate newly configures is lent to the base, so the commit that first binds baseURL is not charged with the anchors the binding makes readable: those links were broken before it and stay pre-existing. A site the base already configures keeps its own configuration on each side. Pointing contentDir at another directory changes what every route reaches, so a link that resolved before and misses now is introduced by the commit that moved it. A declaration can be read under the candidate’s alone because dropping or replacing one is reported as a control finding of its own; a configuration file has no such report, so only what the candidate adds is shared.

What a repository declares about its own build

Every rule so far is selected by a file in the tree, so a repository that keeps no configuration gets no rule at all. That is the common case for documents whose site is built somewhere else. Of the 54 repositories behind this page, 16 hold no generator configuration at all, 4,133 scanned documents between them. Some are plain trees that a forge renders, and they want nothing here. The rest are content a generator reads from another place: Grafana’s Hugo configuration is in a Docker image and a sibling repository, and Babel’s site is built from babel/website.

So the repository says it, since nothing in such a tree can. .amiss/router.yml names one router on a line of its own, router: directory-pages, and the directory holding that file is the root the rule anchors at, where the configuration file would have been. The nearest declaration above the document answers. The table below is every name the line accepts.

Declared routerTurns on
mdbooknothing
vitepressnothing
vitepress-readmenothing
antoranothing
docusaurussite-alias, content-root, document-id
mkdocsdirectory-url
sphinxsource-root
mdbook-pagesnothing
zolacontent-root
astronothing
nextjs-apppage-directory
eleventynothing
hugonothing
directory-pagespage-url
jekyllnothing

The file holds the declaration and nothing else. Comments, blank lines, a --- marker, CRLF endings and a byte-order mark are YAML’s own and read as nothing. Any other line refuses the run as CONFIGURATION_INVALID at the file: a name outside the table, a name in the wrong case, a base without its leading slash or with no router line beside it, a repeated key, or a key this page does not define. A typo therefore never passes as a declaration that does nothing. Only the candidate’s files are held to this, so the commit that repairs one completes.

A declaration turns on the spellings that resolve a destination and no others. built-route and built-page are not in that table and cannot be reached from a file a repository writes, because those two withhold an answer rather than serve a file: a repository that could reach them could clear its own findings with one line. What a declaration does reach is bounded the way every spelling is. It can move a destination onto a file the tree already holds, and it cannot clear a destination the tree lacks, so declaring hugo or jekyll or astro turns on no spelling at all. There is still no ignore file and no way to silence a finding. The base key below is the other half of the file and is read whichever router the line above it names.

nextjs-app is the Next.js app router, which serves app/docs/guide/page.mdx (or page.md) at /docs/guide, with no trailing slash. page-directory reaches that file from the route it is served at, so guide is guide/page.mdx. A link in such a page is read the way a browser reads it, from the page’s URL, which is the directory holding the page with no trailing slash, so ../other in app/docs/guide/page.mdx is app/other/page.mdx rather than app/docs/other. A route names its page and not its directory, so a route directory holding no page is missing, which is the 404 the site serves, while a destination whose last segment carries an extension stays the file it names. An image stays beside the file, because the MDX loader turns it into a module import. nextra’s own documentation had five links reported missing that its site serves, all read one directory too deep, and a site setting trailingSlash or grouping routes in (group) directories is not modelled.

page-url is the row a declaration exists for. A site of that shape publishes a page at a directory of its own name and rewrites no destination, so a relative destination resolves against the page URL, one level deeper than the source file’s directory, and a Markdown link is read that way as well as an <a href>. A page bundle is published at the directory holding it, so _index.md and index.md are the sources a destination naming that directory reaches. The reading from the document’s own directory comes first, so a destination that already reached a file beside the source still reaches that file and still fixes the intent a finding names. The page URL is a candidate added, never one replacing another.

The row is named for the shape because no one generator owns it. Hugo builds it by default, Jekyll’s permalink: pretty and its /:collection/:path/ template produce it, and Eleventy and Astro build it unless asked for a file. It was called hugo-pages in the release before this one, so a Jekyll repository had to declare Hugo to describe its own site. A name for each generator would be one reading under four spellings, every one a row to keep and a row to test. mkdocs publishes this shape too and keeps its own row, since it rewrites a Markdown link back to the source path and leaves raw HTML alone, so only raw HTML is read against the page URL there.

This is the one row a configuration file does not select, and Hugo’s own documentation is why. hugo.toml says Hugo builds the tree and nothing about the URLs it serves: uglyURLs moves every page, a permalink template moves it again, and a render-link.html render hook rewrites the destination before any of that, which is what hugoDocs does to its own [glob pattern](g). Jekyll’s _config.yml carries the same question in its permalink line, and neither answer is in the tree, so a tree carrying either file keeps the built-route boundary it had. The declaration is the repository’s own word that its pages are published at a directory of their own name with nothing rewritten, which is a claim only a person can make.

Grafana is the case it was built against. docs/sources/setup-grafana/set-up-grafana-live.md writes [ha_engine_address](../configure-grafana/#ha_engine_address), and the page it reaches is docs/sources/setup-grafana/configure-grafana/_index.md, one directory deeper than the source-relative reading, which is why 544 missing targets were reported for a tree whose maintainers had not broken 544 links. With router: directory-pages under docs/sources that count is 278. The 266 claims that went are one shape. A separate reading of the page URL was run over the references that resolve where they stood: 270 of 271 match it, and the one left over reached a file beside its own source once the trailing slash stopped naming a tree. Nothing moved into the undecided class, which stayed at 1,153 rows, and every one of the 52 anchor claims still reports. Another thirty-one joined them, since a path that resolves has its fragment read: ../../datasources/tempo/#span-filters reaches the Tempo page, which publishes no such heading now that span filters are a page of their own.

A page that moved leaves the URL it was served at behind, and that block is read under the same declaration. Hugo lists those URLs under aliases in front matter, serves a redirect at each one, and reads a relative entry against the directory the page’s own URL sits in, one level above the route the page is published at. Grafana’s own blocks show it: 784 of its entries carry the URL they produce in a trailing comment, 735 of those match that reading, and 6 match the page URL itself. Hugo’s documentation says the same, giving old-name and ../old/path on content/examples/example-1.en.md as /en/examples/old-name/ and /en/old/path/.

The grammar is a key opening a line with no value of its own, then the lines under it opening with a dash and a space, and the first line shaped any other way closes the block. A flow sequence, a plain scalar, and an entry opening with a slash are declined rather than guessed at, so this stays a reader of one block and not a reader of YAML. A URL two pages claim is left out the way a route two pages publish is, and the page that answers is a file the tree holds, so a block widens what resolves and cannot invent a target. Only the reading anchored at the page URL is answered from it, since the reading beside the source is a path and no URL at all: answering that one would clear docs/sources/administration/cli.md’s ../developers/http_api/user/, whose page URL is administration/developers/http_api/user and is claimed by nothing.

Grafana’s 278 becomes 149. Each of the 129 claims that went names a URL exactly one page declares, out of the 1,276 single-claimant URLs its 1,336 relative entries spell, and no claim arrived. The undecided class stayed at 1,153 rows again. Of the 195 paths that were missing, 152 resolve, and 23 of those become anchor claims because a path that resolves has its fragment read: ../../panels-visualizations/visualizations/time-series/#connect-null-values reaches the time series page, which publishes no such heading now that its options come from a shared file.

Which side of a comparison the declaration is read from decides whether a repository can adopt one at all. It is tree state, so the commit that writes the file has a base without it, and read each side its own way, Grafana’s adoption reports 476 claims resolved and 210 introduced, mostly anchors that become readable the moment their path resolves. Every one of the 210 was already broken and the author of that commit touched none of them. So both sides are read under the declarations the candidate holds. Grafana’s adoption then reports the 278 the tree has either way, all of them pre-existing, and nothing introduced.

Removing one is that rule read backwards, and that direction is reported. The candidate declares nothing, so neither side does, and Grafana’s 544 claims come back, every one pre-existing. What the symmetry cannot do by itself is say that a claim went: a fragment is read because its path resolved, and once the declaration goes the path is missing instead, or, under a built-route generator above the document, a declared boundary. So a declaration the base held that the candidate does not hold identically is a policy-weakened control finding at the file that held it, router/declaration-removed where the candidate declares nothing there and router/declaration-replaced where it names another router or another base. That is the one thing the declaration reports about itself, and it reports it in every profile.

The second key answers the destinations that open with a slash. Those are 54,717 of the 117,747 in-scope references across 56 repositories, 46.5 percent, more than the resolver answers, and each one names a page of a site rather than a file. Which site is the part no tree holds: the Kubernetes community repository writes /docs/comms/slack/ and means kubernetes.io. So the declaration says it. base is the URL path the directory it sits in is served at, and a route opening with that base is a path under that directory:

router: astro
base: /

Under that file at src/content/docs, /en/guides/astro-components/ is src/content/docs/en/guides/astro-components, which the extensionless spelling reads as the .mdx beside it, and the trailing slash names the page rather than a tree the way it does under page-url. Grafana serves docs/sources at /docs/grafana/latest/ and writes that prefix into its own links, so its base carries it and a route without it stays where it was.

The tree has to hold the page, on the side being read or, for a page a change deletes, on the base that served it: an unchanged link to a page the candidate dropped is then a missing target at that page’s path rather than an undecided route. A route that reaches no file keeps the boundary it had, because a site serves routes its own build makes up and a tree can enumerate only what it holds: Astro’s translated pages fall back to English, Kubernetes generates its API reference from OpenAPI, its blog is published at a permalink its sources do not spell, and its images come from a directory the declaration does not cover. All four stay declined, and across five declared trees not one missing-target claim arrived. What does arrive is anchors, because a path that resolves has its fragment read: 61 anchor claims against 15,890 references resolved, every one read by hand. Fifty-six name a heading the page it reaches does not publish, three name one a Hugo shortcode writes, and two name an anchor a generated reference page spells three ways. A declaration with no base line reads exactly as it did before.

A route naming a directory reaches that directory, since a tree holding guides/index.mdx holds guides, and a fragment on a directory stays undecided the way it already does for ../guides/#setup. Where Docusaurus, Starlight or Hugo publishes a page at the directory, its index or Hugo’s _index.md, the route reaches that page instead, and so does a relative destination naming the directory without a trailing slash. That is 1,106 of those references, with 302 fragments left unread. Nothing else about the reading is new: the destination is a path under the declared directory, read by the spellings every other path is read by.

Four repositories show what the file does, each read whole on 2026-09-20 under the observe profile, once as it stands and once with the file below staged.

Astro’s documentation keeps its pages under src/content/docs and serves that directory at the root of its site. Before its Starlight configuration was read, all 10,323 of its slash-rooted destinations were routes nothing in the tree answered, and the file went at src/content/docs/.amiss/router.yml:

router: astro
base: /

The router line turns on no spelling there, since built-route withholds an answer rather than serving a file, and the base is read whichever router the line above it names. Site routes fell from 10,323 to 1,647 and resolved references rose from 3,470 to 12,133. Three claims arrived, each an anchor on a page that only resolved then, and no missing path arrived at all. The configuration now says the same thing on its own, so the file moves nothing there today.

Kubernetes serves its website out of content/en and its hugo.toml says so, which is why that repository is no longer an example here: the configuration is read where it sits, and a declaration staged beside it moves not one reference. The section below is what answers it.

Jekyll’s own repository is why the row is not named after Hugo. docs/_config.yml sets permalink: "/:collection/:path/", which publishes every page at a directory of its own name, and one line at docs/.amiss/router.yml says so:

router: directory-pages

That resolves 19 references, 76 becoming 95, and moves no claim: the four that are missing sit in .github/, outside the tree the file covers, and stay missing. It is the smallest useful declaration on this page and the whole of what a Jekyll repository writes.

Grafana is the tree above, read again here on a newer clone than those paragraphs were written against. Both keys go at docs/sources/.amiss/router.yml:

router: directory-pages
base: /docs/grafana/latest/

Its claims fall from 546 to 153, and the 494 missing paths become 45. The 52 anchor claims become 108, since a path that resolves has its fragment read. Without the base line the count is 151 instead of 153, so the second key is worth two claims and 52 references on this tree, where on Astro’s, before its Starlight configuration was read, it was worth all 8,663.

Nothing in the output suggests writing one, and nothing honestly could. A tree says whether a generator is configured inside it; it never says whether its documents are published at all. So one structural fact covers both a repository built elsewhere and a repository with no site, whose missing targets are simply broken. A note keyed to that fact would have reached the Kubernetes community repository’s 254 missing targets, bat’s 21 and vuejs-docs’ one, none of which a declaration should touch.

That repository is also the one to read before writing a base, because its slash-rooted destinations do not name one site. communication/slack-moderation.md writes /docs/comms/slack/#reporting-a-problem and means k8s.dev, other pages mean kubernetes.io, and /sig-list.md and /CLA.md mean the repository root, which no site serves them from. A base: / at its own root reads all of them against the tree, and 737 references resolve where 828 routes were declined. Its 106 missing paths do not move, since a base can no more invent a target than any other spelling can, but 50 of the newly resolved paths carry a fragment the page they now reach does not publish, so 254 claims become 304. The key is one claim about where one directory is served, and a tree whose links point at three sites has none to make.

What this costs

A repository with no site at all now resolves ./guide when guide.md exists, and on github.com that link is a 404. This is the same trade the renderer rules already make for heading identities, taken for the same reason: a false missing target teaches maintainers to ignore the tool, and the union of what real renderers do is the honest way to avoid one. The three spellings are selected by nothing a repository declares. A generator rule is selected by a configuration file, which is a lever the pull request under review can pull, and the lever is bounded the same way the spellings are: it can move a destination onto a file the tree already holds, and it cannot clear a destination the tree lacks.

Routers outside the table serve spellings this check will not match. Four repositories built on them were run on 2026-07-26 to find out what a new row would have to answer, each read whole against an empty base under the observe profile. Three completed, at hugoDocs 620696ab3b07, jest f49721c78e19, and jekyll 7697d249793d, and their counts below are from those reports. The fourth, docusaurus 16f537309e35, produced no report: it ran to the end of evaluation and then refused at output, so nothing is counted from it. Of the three that completed, jest’s shape became a spelling and took a later survey to write, while Hugo’s and Jekyll’s became rows that declare a boundary instead of serving a path.

Hugo’s own documentation writes [glob pattern](g) and resolves g in its own render-link.html, 734 references to a path that exists nowhere. Its 101 missing anchors are two further mechanisms: 71 name a definition-list term, which its configuration turns into an identity with autoDefinitionTermID, so module.md’s files term is published as <dt id=files> while the pinned grammar has no definition list to read at all; 28 name a heading pulled in by an {{% include %}} shortcode. Jest, on Docusaurus, links a document by the identity that document declares in its own front matter: Configuration.md opens with id: configuration, its page is published at that name, and 104 references reach it by URL rather than by path. That one is the document-id spelling above, written after the same shape turned up across six ordinary projects. On a later clone it answered 127 of jest’s 130 missing paths, and the three left are real: tutorial-react is still in two versioned copies and gone from the current documents and the two newest copies.

Docusaurus itself refused at first, its findings serializing past the output reservation described in Limits and refusals. With that raised it scans, and with the identity its headings declare in an MDX comment now read it reports 198 missing references rather than 807. Those are the @site alias, a webpack path with no tree meaning, and identities that arrive through MDX imports of partial files.

Jekyll is the one that looks like a missing row, and the harvest says otherwise. Its own site writes reviewing-a-pull-request/ from a maintaining index, which reaches maintaining/reviewing-a-pull-request.md, and ../ubuntu/ from an installation page, which reaches nothing; jekyllrb.com serves the first and returns 404 for the second, so both of our answers match the site. A trailing slash reaching the sibling source file is real there because that site’s permalinks mirror its paths, which is a configuration and not a property of Jekyll. Asked the same destination, mdbook serves nothing, mkdocs rejects it in the source with a warning naming the .md file, and vitepress emits it verbatim into a build holding only page.html, dead on any host despite its own dead-link checker accepting it. One router, by configuration, is not a spelling. What that configuration does decide is where every page lands, and the built-route row says so rather than guessing at the template.

Hugo’s g was the case for the generated class, arriving as transclusion and as a repository’s own render hook. The exact-path core of that class is answered now, from the tracked ignore file recorded in Reference coverage, and both of those arrivals still sit outside it. What moved is the verdict rather than the class: a destination a render hook rewrites is undecided instead of missing, so hugoDocs went from 618 missing targets to 123, every one of them an anchor, and the anchor rules added since have taken it to none.

Last change: , commit: d7157396