docs: migrate site from astro to fumadocs
Replaces Astro 6 + Starlight with Fumadocs on Next 16 static export, ported from the working setup in the sibling playwright-page-object repo rather than authored fresh.
URL parity is exact
This is what made the migration low risk. Starlight (base: "", default trailing slash) and Fumadocs (baseUrl: "/" + Next trailingSlash: true) emit identical paths, so all 15 doc routes plus / survive with no redirects.
scripts/validate-routes.mjs pins this as a build gate — it asserts every route emitted an out/**/index.html and that each canonical is https://path.shmakov.tools<route>. The route list in that script is the old Starlight route set, unchanged.
Content barely moved
Every page already carried just title and description, which satisfies the Fumadocs pageSchema, and no code fence used expressiveCode meta — so Shiki is a drop-in on the same github-light/github-dark themes. Three content changes total:
- Two Starlight
:::noteasides →<Callout type="info">(guides/templates.mdx,integrations/zod.mdx) index.mdx(template: splash) →app/(home)/page.tsx- Starlight's explicit
sidebararray → fivemeta.jsonfiles preserving the existing order
All 15 content files land as git renames (100% similarity on 13, 97%/99% on the two Callout conversions), so per-file history survives — which matters because Fumadocs reads lastModified from git log per file.
Theme
Yellow accent over the neutral.css token preset. --color-fd-primary-foreground is forced near-black in both themes: the preset's light value is near-white, which on yellow is ~2.6:1 and fails WCAG. The shipped pair gives 6.6:1 light / 11.7:1 dark, verified against the compiled CSS rather than the source, since the override has to win on cascade order.
Brand assets are generated in-repo — favicon, logo, hero, and a 1200×630 OG card sharing one indent-cascade motif. scripts/render-png.mjs rasterises the card so the PNG cannot drift from its SVG source.
Blog ships without posts
Infrastructure is included, but blog/[slug] and blog/tags/[tag] sit under a Next private folder (app/(home)/blog/_pending/). output: "export" fails the build outright on an empty generateStaticParams() — it does not silently emit nothing. /blog/, the author page and the RSS feed build fine, because the author list is a static map rather than post-derived.
Activation is two git mvs, documented in app/(home)/blog/_pending/README.md. I chose this over inventing a placeholder post so no fabricated content ships.
CI
Build output moves docs/dist → docs/out, so the Pages artifact path changes with it. The workflow now also gates PRs and runs lint → typecheck → build → validate:links → validate:routes, and drops the root-sitemap dispatch that no longer has a consumer (per the 2026-08-06 decision that each custom-domain host owns its sitemap). fetch-depth: 0 is retained — Fumadocs needs git history for lastModified.
Verification
All green locally: build, typecheck, lint, validate:links (0 errors), validate:routes (18 routes). Exported route list diffed against the old Astro route set — exact match. Pre-commit hook ran the library suite too: 205 runtime + 60 type tests passing.
Reviewer notes
/sitemap-index.xml→/sitemap.xml. Astro used the former, Next emits the latter. If the old path is submitted in Search Console it will 404 after deploy and needs resubmitting.- Four biome warnings are intentional and match the reference repo: two
noImgElement(next/imagebuys nothing withimages.unoptimized+ static export of SVGs) and twonoImportantStyles(scroll-lock overrides that must defeatreact-remove-scroll).
🤖 Generated with Claude Code
Summary by cubic
Migrates the docs site from astro/@astrojs/starlight to fumadocs on next 16 static export. Old: builds to docs/dist with /sitemap-index.xml; new: builds to docs/out with /sitemap.xml. All documentation URLs are unchanged; new public routes include /blog/, author pages, and an RSS feed.
- URL parity is enforced:
scripts/validate-routes.mjsasserts every exported route, canonical URL, and selected titles/H1s match the legacy set. - Content moved with minimal edits: homepage is a React page; two Starlight notes →
<Callout type="info">; sidebar arrays →meta.json. Files were git-renamed to preservelastModified. - Theme: yellow accent over
fumadocs-uineutral; forces--color-fd-primary-foregroundto near-black in both themes for WCAG contrast. - Blog infrastructure ships without posts:
/blog/, author pages, and/blog/rss.xmlare live; post and tag routes sit under a private_pending/folder until there is content. - Search is static:
app/api/searchserves the index; a client dialog is wired viafumadocs-core’s static client. - Robots and sitemap are generated by Next routes:
app/robots.tspoints to/sitemap.xml. - CI: Pages workflow now runs on PRs, executes lint → typecheck → build → link and route validators, and uploads
docs/outwithactions/deploy-pages@v5. Root sitemap dispatch is removed.
Rollout and follow-ups
- Update Search Console to use
/sitemap.xml(the old/sitemap-index.xmlwill 404 after deploy). - No redirects are required; documentation route set is identical.
- To enable blog posts later: move
app/(home)/blog/_pending/[slug]→app/(home)/blog/[slug]and_pending/tags→tags, re-add the “Blog” nav link, and include the new routes inscripts/validate-links.mjsandscripts/validate-routes.mjs.