Skip to content

docs: fix the site's broken internal links and check every built link - #591

Merged
bonk-ai[bot] merged 1 commit into
mainfrom
fix/site-relative-md-links
Oct 12, 2026
Merged

bonk-ai[bot] merged 1 commit into
mainfrom
fix/site-relative-md-links

Conversation

@bonk-ai

@bonk-ai bonk-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

What and why

The published site had 444 broken internal links (143 distinct targets), all from relative .md links in the docs tree. For example, [daemon API reference](rpc-tools.md) on https://laithalsaadoon.github.io/microvms-agentd/reference/public-api/ fetched /microvms-agentd/reference/public-api/rpc-tools.md, a 404. Sätteri emits a link target as written, and each rendered page is served one directory deeper than its source (reference/public-api.md at /reference/public-api/), so every relative .md link on a rendered page resolved one level too deep: 148 links on 29 pages. starlight-llms-txt flattens the rendered pages into llms-full.txt and llms-small.txt, which carried the same 148 links each, resolved from the site root: 144 landed beside the bundle and 152 climbed above the base with ../. The raw .md twins were correct, because there each relative target sits beside its source.

No gate resolved these. starlight-links-validator was configured to skip every .md link and every relative link, and the config comment that justified it said Astro rewrites a relative link to a route in the rendered page. It does not. tests/raw-route-links.test.ts resolves links in the twins only.

The fix

  • site/src/lib/relative-md-links.ts is a Sätteri mdast link visitor. It resolves a relative .md target against the source document's path under src/content/docs and rewrites it to the target page's root-relative route, keeping the fragment: rpc-tools.md becomes /reference/rpc-tools/. starlightBasePath(), whose Sätteri pass is pushed after the configured plugins, then adds the base. A relative .md target with no file under the content root fails the build with the document and line, and so does one whose path uses characters outside [a-z0-9_-], because Astro slugs those with github-slugger and the lowercased path would no longer be the route. Every page in the tree meets that rule. The twins never pass through this processor and keep their relative links.
  • site/src/lib/jsonld.ts builds the page's Markdown encoding URL with rawMarkdownUrl, the helper the <head> alternate link already uses. It built the URL by hand and named the root twin with Starlight's dotfile spelling, /microvms-agentd/.md (live: 404), while the site publishes it as index.md (live: 200).
  • site/src/components/Head.astro emits the og:image and twitter:image tags only on a page that has a card. The card route renders one card per docs collection entry, keyed by ogId, and Starlight's built-in 404 page is outside that collection, so it named /og/404.png, which the build never wrote (live: 404).

The checks

  • site/tests/built-links.test.ts (default vitest tier, so CI's required "build and gate the docs site" job runs it in "gate the built surface", and the deploy job publishes only an artifact that passed it) resolves every link the way a browser does, against the URL each file is served at:

    • every href and src on every built HTML page (<script src> included), and every <meta content> URL on the site's origin;
    • every url and @id on the site's origin in each page's JSON-LD graph (an @id fragment such as #article names a node, so only its page is checked);
    • every Markdown link in the raw twins and the llms bundles (inline links and images, reference definitions, autolinks, and href or src in raw HTML), with fenced code and code spans removed first;
    • every sitemap <loc>;
    • every fragment into a rendered page, against that page's ids.

    A protocol-relative link (//x/) fails on any surface, because a browser reads its first segment as a host; it is what joining an empty base onto a leading slash produces. A target passes when GitHub Pages would serve a file for it (x/index.html at x/, x/ from x, x.html at x). External links stay with lychee (mise run docs:links and the weekly links.yml). One link is allowed by name: the 404 page's canonical URL and og:url, which Starlight sets to /404/, a route with no file because Pages serves 404.html at every missing path. A relative link on the 404 page fails its own case, because it would resolve against whichever missing URL was requested.

  • starlight-links-validator now applies errorOnRelativeLinks (its default), and its exclusion covers root-relative .md twin links only, so a relative .md link the rewrite missed fails astro build.

Evidence

  • Live site, crawled 2026-10-12 from main 24b4712: 248 HTML pages, 248 twins and 3 llms bundles. 88,452 internal link occurrences, 904 distinct targets, 143 of them 404 (444 occurrences, the three patterns above). 0 fragment misses across 9,660 fragment links into HTML pages. 1,246 distinct external URLs and 0 broken: 298 checked returned 2xx, 744 to claude.ai and chatgpt.com (the per-page "Open in" links) answered the crawler with 403 and are unverified, and 204 commit-pinned blob permalinks were sampled (30 of 30 returned 200).
  • Built site on this branch, measured with the same rules: 85,771 internal links across 277 HTML pages (248 pages, 28 legacy-route redirect stubs and the 404 page), 249 twins and 3 bundles, 0 unresolved and 0 fragment misses. On main the same measurement finds the 444.
  • /reference/public-api/ now carries href="/microvms-agentd/reference/rpc-tools/" three times and no relative href; llms-full.txt carries /microvms-agentd/reference/rpc-tools/; the twin reference/public-api.md still links rpc-tools.md, which resolves to /microvms-agentd/reference/rpc-tools.md.
  • Every one of the 248 content pages still has its card; the 404 page has none.
  • The repository's own Markdown, as GitHub renders it: lychee 0.24.2 offline with fragments over the 68 tracked .md files outside site/authored/ reports 0 errors, and online with .config/lychee.toml 0 errors (55 OK, 5 redirects). The 174 offline errors in site/authored/ are site routes such as /learn/tutorial/install, which tests/authored-links.test.ts resolves against the published pages and which resolve on the built site.
  • mise run docs:check on this tree: EXIT=0 in 176 s; biome and cspell clean, astro check 0 errors, vitest Test Files 15 passed (15) and Tests 223 passed (223) (main: 216, and this file adds 7). The rendered-pages case took 1.7 s at a load average of 70 on 16 CPUs. An earlier run under that load, before the case read each page once, timed out at vitest's 5-second default there and in agent-surface.test.ts's existing "emits no protocol-relative href anywhere"; the rendered-pages case now states a 60-second timeout.
  • mise run check, run bare: EXIT=0 in 590 s, 69 test result: lines, 1799 passed, 0 failed, [security] advisories ok, bans ok, licenses ok, sources ok, Prose: 0 errors, 4111 warnings (main: 4111, so no Vale ceiling moves). It ran before the last edits to site/src/lib/ and site/tests/, which none of its tasks read (its lint is Rust and Python, and Vale reads .md and .mdx).
  • An independent read-only review of the diff cross-checked the test's extractors against parse5 and mdast over the build. It found the test skipped <script src> (1,211 references on 249 pages) and treated // links as external, plus smaller gaps (malformed percent-encoding, footnotes read as definitions, the bare base, autolinks and raw HTML in twins, JSON-LD). Each is fixed above, and the JSON-LD case then found the .md encoding URL.
  • mise run pr-body:check -- --body <this body>: EXIT=0.

Guards

The registry's suites are rust, script and bindings, and a scratch worktree has no site/node_modules, so these site guards were broken by hand, as dts-consumer.toml and parity-wiring.toml record for the other site tests. Each was restored and the tree passes again (see Evidence).

  • built-links.test.ts's JSON-LD case: with jsonld.ts as on main, it failed on index.html -> https://laithalsaadoon.github.io/microvms-agentd/.md resolves to /microvms-agentd/.md, which was not built.
  • built-links.test.ts: with relativeMdLinks(CONTENT_ROOT) removed from astro.config.ts and the validator set back to exclude: ({ link }) => link.endsWith(".md") and errorOnRelativeLinks: false, astro build passed and the test failed two cases: 148 offenders on rendered pages (among them reference/public-api/index.html -> rpc-tools.md resolves to /microvms-agentd/reference/public-api/rpc-tools.md, which was not built) and 296 in the llms bundles. With the original Head.astro, it failed the rendered-pages case on 404.html -> …/og/404.png, twice (og:image and twitter:image).
  • The validator: with the plugin removed and the new validator settings, mise run docs:build failed with Found 148 invalid links in 29 files, each marked relative link.
  • The plugin's own refusal: with ](rpc-tools.md) changed to ](rpc-toolz.md) in the synced src/content/docs/reference/public-api.md, astro build failed with docs-relative-md-links: …/reference/public-api.md:11: \rpc-toolz.md` resolves to `reference/rpc-toolz.md`, which is not a page. docs:syncwould refuse the same link indocs/` first.

Follow-ups

  • None owed by this change. A post-deploy crawl of the live site was weighed and left out: the deploy job publishes the artifact the gate step checked, so it would read the same bytes.

Platform claims

  • Not applicable: this changes no claim about AWS behavior.

Scope

  • This is not an orchestrator or a fork implementation
    (see docs/STRATEGY.md).

Relative `.md` links in the docs tree are now rewritten to page routes
on the rendered site (`src/lib/relative-md-links.ts`), so `rpc-tools.md`
on /reference/public-api/ links /reference/rpc-tools/ instead of a 404
one directory too deep. The llms bundles inherit the fix. The raw twins
keep their relative links, which resolve there.

The JSON-LD graph names the root twin as `index.md` (it named the
dotfile `.md`, a 404), and the 404 page no longer names a social card
the build never writes.

`tests/built-links.test.ts` resolves every link on every built page,
twin, llms bundle, JSON-LD graph and sitemap against dist/ the way a
browser does, fragments included, and the links validator now fails the
build on any relative link that reaches it.
@bonk-ai
bonk-ai Bot merged commit 6839b83 into main Oct 12, 2026
33 checks passed
@bonk-ai
bonk-ai Bot deleted the fix/site-relative-md-links branch October 12, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants