0132. The docs site renders docs/ in place with Astro, preactcn and Pagefind, and deploys to GitHub Pages
Status: Accepted · Date: 2026-11-06 · Design section: Documentation · Builds on: 0029, 0070
Decided: The documentation lives only as Markdown in docs/ and is published by a static Astro site in site/ (Preact islands, Tailwind v4, preactcn components, Pagefind search) to GitHub Pages at https://ichor.maesh.dev/; a page is added or changed by editing Markdown alone, and the build fails on a broken internal link.
Context
The guides, the architecture document, the ADRs and (generated) the CLI and CRD reference are Markdown in docs/, readable on GitHub. They need a searchable, navigable site, without a second copy to keep in step and without a list of pages or a per-page file that someone has to maintain.
Decision
- Astro, static, under a base.
site/is an Astro 7 project (scaffolded withnpm create astro@latest,astro add preactandastro add tailwind) with static output.siteandbasedefault tohttps://ichor.maesh.devand/and are set byDOCS_SITEandDOCS_BASEfor a fork or another domain.make docs-sitebuilds it (npm ci,astro build, the link check, the Pagefind index) intosite/dist;make docs-devis the live preview,make docs-previewserves the build,make docs-checkalso drives it in headless Chromium (hack/docs-shots.mjs: search, hideable sidebar, links, phone width, screenshots)..github/workflows/docs.ymlbuilds on a push to main that touchesdocs/**,site/**orCHANGELOG.md(and by hand) and deploys withactions/upload-pages-artifactandactions/deploy-pages, pinned by commit SHA, withpages: writeandid-token: writeon the deploy job only. The version badge is the newest heading ofCHANGELOG.md(DOCS_VERSIONoverrides it). - The Markdown is read in place. One content collection,
docs, with theglob()loader over../docs(ids are the paths without.md;README.mdandindex.mdare their directory's page). Front matter is optional and onlytitle,description,sidebar_labelandsidebar_ordermean anything; the title otherwise is the first#heading. The sidebar is generated from the directory tree: the directory is the section, pages are ordered bysidebar_orderthen title, the decision records newest first and collapsed, and any new file or directory appears by itself. The only names in code are the labels and order of the directories that exist today (site/src/lib/docs.ts). Ifdocs/index.mddoes not exist, the home page is generated from the sections.docs/contributing-docs.mdis the author's guide. - The markdown processor is
unified(). Astro 7 ships Sätteri as the default Markdown processor; its plugin API does not document access to the file path orhNamenode data, which the link rewriting and the callouts need, so@astrojs/markdown-remarkis installed andunified()is the processor, with three small remark plugins (site/plugins/docs.mjs) andsmartypants: false(GitHub does not turn--flaginto a dash).- Relative links between
.mdfiles, with their#anchors, become site URLs; relative links that leavedocs/become GitHub links to the file or directory; a link to something that does not exist fails the build. After the buildsite/scripts/check-links.mjschecks every internalhrefand#headingof the output, so a Markdown-only edit cannot publish a broken link. - The Markdown's own
#title is dropped (the layout prints the title as the h1), as is a first heading that repeats the title (the generated CLI pages). - GitHub alerts (
> [!NOTE],TIP,IMPORTANT,WARNING,CAUTION) render on GitHub as they are and on the site as the preactcnAlert(Callout.tsx, applied byProse.astro;WARNINGandCAUTIONuse its destructive variant). - Code blocks are Astro's Shiki output with the two themes as CSS variables; the copy button is added to every
preby theCodeCopyisland, not by authors.
- Relative links between
- preactcn, as documented.
site/components.jsonmirrorsui/components.json(registry@preactcn); components came only fromnpx shadcn@latest add @preactcn/<name>(sidebar, sheet, command, dialog, breadcrumb, card, badge, alert, tabs, separator, scroll-area, button, tooltip, collapsible, accordion, kbd, navigation-menu, dropdown-menu) and the theme fromshadcn init(without the Geist font it adds: the UI uses the default stack too). The layout is the Sidebar (left, hideable with the header button or Ctrl/Cmd-B, the choice kept inlocalStorage, a Sheet belowmd), a header with the version Badge, the search button and the light/dark toggle (system preference by default, a saved choice wins), Breadcrumb, Cards for previous and next, a ScrollArea and a Collapsible for "On this page" (a column fromxl, a collapsible at the top of the article below). Only layout utilities are ours. The one addition is the@tailwindcss/typographyplugin for the Markdown body, as it has no preactcn counterpart. - Pagefind, with our own dialog.
pagefind --site distruns after the Astro build (npm run build), rather than theastro-pagefindintegration, which would hide the order of the link check and the index and add a dependency for one command. The article isdata-pagefind-body; breadcrumbs, the page list and the previous/next links aredata-pagefind-ignore; the title has weight 10. The UI is the preactcnCommandin a dialog (SearchDialog.tsx) callingpagefind.debouncedSearchandresult.data(), results link to the matching heading; Ctrl/Cmd-K opens it. The bundle is imported from<base>pagefind/pagefind.js, so it works under/ichor;make docs-devhas no index (the dialog says so). - The ADR index links to the ADRs.
internal/tools/adrindexwrites each number in the table as a link to its file, so the index page of the Decisions section is navigable (the generated table is otherwise unchanged).
Consequences
- Publishing is on hold (nothing is pushed by this change); once the repository's Pages source is set to "GitHub Actions" the workflow publishes on the next push to main.
@astrojs/preactbrought Preact 11, the UI pins 10.29.8; the site is a separate package, so the two are not shared. Revisit if the UI moves to 11 or a shared component is wanted.- Front matter shows as a small table on GitHub; docs use it only where it matters (
title,descriptionandsidebar_orderon the overview, the concepts and the reference). - Not in the registry or not usable: lucide-preact 1.x has no GitHub brand icon, so the repository link is text with an external-link icon; Markdown tables and lists are rendered by the Markdown pipeline and styled by the typography plugin, not by the preactcn
Table, because Markdown output cannot be mapped to Preact components without MDX (which the "edit Markdown only" rule excludes). - The sidebar state is applied after hydration, so a page loaded with the sidebar hidden shows it open for a moment.
- Sätteri can replace
unified()when its plugin API covers file paths and node data; the three plugins are small.
Amendment, 2026-11-06: page actions, heading links, Markdown routes and versioned docs
Decided: every page can be copied or shared as Markdown, every h2/h3 has a copy-link anchor, and the docs are published per version (next from main, one per release tag) with a version switcher, all from Markdown on main alone and nothing per page.
- Page actions. The
PageActionsisland sits at the top right of the title: a preactcnButtonGroupwith "Copy page" (copies the Markdown) and aDropdownMenuwith "Copy as Markdown", "View as Markdown" (the static.md), "Copy link" (the canonical URL) and "Share…" (navigator.share, listed only where it exists). Feedback is the preactcnSonnertoast (added withnpx shadcn@latest add @preactcn/button-group @preactcn/sonner). The clipboard write goes through aClipboardItemholding the pendingfetchof the.md, which Safari needs to keep the user gesture. - Markdown routes. An Astro endpoint,
src/pages/[...slug].md.ts, emits<route>.md(index.mdfor the home page) for every entry of thedocscollection next to its HTML. The text is the entry's body (front matter is not in it), starts with the page's#heading (the title is prepended when the file has none, and a generated CLI page's## ichor adoptis promoted), and has every relative link made absolute byabsoluteMarkdown(site/plugins/docs.mjs), which sharesresolveLinkwith the remark plugin: links to pages become site URLs, links out ofdocs/become GitHub URLs,#anchorspoint at the page; fenced code and inline code are left alone and a missing target fails the build as before./llms.txtlists the pages by sidebar section with their descriptions and links to the.mdfiles,/llms-full.txtconcatenates all of them in sidebar order. Pagefind only reads.htmlfiles, so none of these are indexed (checked: the page count is unchanged);check-links.mjsalso reads only HTML. - Heading links. A rehype step in
Prose.astroappends a link to itself (a stock ghosticon-xsButton's classes, the lucideLinkicon,aria-labelnaming the heading) to every h2 and h3, shown on hover or keyboard focus and always on touch screens (hover: none); it is a plain anchor, so it works without scripts and the browser moves the address bar, andPageActionscopies its URL on click. Headings also getscroll-mt-20for the sticky header. - Theme icon. The toggle renders both icons and CSS shows the one for the
darkclass set by the head script (hidden dark:block,dark:hidden), so the first paint is right. - Versions come from git, not copies. One build is one version:
DOCS_VERSIONisnext(default, the working tree'sdocs/) or a release tagvX.Y.Z, built at<DOCS_BASE>/<version>/(DOCS_BASE, default/ichor, is where all versions sit). For a tag,site/scripts/build-all.mjsexports the tag withgit archiveinto a temporary directory and setsDOCS_REPO, which replaces the repository root, sodocs/and the files that links point out to are those of the tag. Every version is built with the currentsite/(a tag's ownsite/is not used, and the first tag, v0.1.0, has none), so all versions share the layout, search and switcher and only the content is frozen. All internal URLs, the canonical URL, the.mdand llms routes and the Pagefind index (<version>/pagefind/, one per version, so search covers the version being read) follow the version; GitHub links out ofdocs/and the page's source link point at the tag, and "Edit this page" (main) becomes "View this page's source on GitHub (vX.Y.Z)" for a release. A tag whose docs cannot be built is left out with a workflow warning, nevernext. Themake docs-sitebuild staysnextonly;make docs-site-allbuilds every version intosite/dist-all, withversions.json, a redirect page underlatest/for every page of the newest release (/latest/…and the site root go to it; tonextwhen there is no release) and itsllms.txtfiles at the root.make docs-checkbuilds all and drives them. - Switcher and banner.
versions.jsonat the site root ([{name, path, isLatest, isNext}],nextfirst, then the releases by semver, newest first) is written by the build fromgit tag -l 'v*'(onlyvX.Y.Z) and fetched at runtime by theVersionSwitcher(aDropdownMenuwith radio items, replacing the static version badge, withnext (main)andvX.Y.Z (latest)labels), so an old build lists later releases. Choosing a version goes to the same page there if aHEADrequest finds it, else to that version's overview. TheVersionBanner(the preactcnAlert) says "Unreleased (main)" onnextand "You are viewing the docs for vX. The latest is vY." on a release that is no longer the newest, with a link to the latest. - Publishing.
.github/workflows/docs.ymlruns on a push to main (docs, site or the workflow) and on av*tag, always checks outmainwith all tags (a tag run must not build with the tag'ssite/), runsmake docs-site-alland deployssite/dist-all, so a new tag adds its docs and its entry to the switcher of every version. TheCHANGELOG.mdtrigger and the badge read from it are gone; theDOCS_VERSIONvariable now names the version being built.
Consequences: a release freezes its docs by being tagged and nothing is maintained per version, but every build rebuilds every tag (about 20 s each, a 30-minute limit); a fix to the site code reaches the old versions on the next publish, a fix to an old version's text needs a new tag (or a docs-only patch release). Without JavaScript the switcher and banner are absent, and an old release shows its banner only after versions.json loads.
Amendment, 2026-11-06: diagrams drawn in draw.io, shown in the theme of the reader
Decided: the docs carry a few diagrams as draw.io files in docs/images/, each a pair of SVGs (NAME.light.drawio.svg, the one that is edited, and NAME.dark.drawio.svg) that carry the editable draw.io model, exported with the draw.io command line from the dev shell, and the site shows the pair by the reader's theme.
- Format and place. An SVG with the draw.io model embedded (
.drawio.svg) shows as a plain image on GitHub and on the site, and opens as a diagram in draw.io (web, desktop, the VS Code extension), so there is one file to keep and nothing to export by hand for a viewer. They sit indocs/images/, not next to the pages: diagrams are reused by two pages (the architecture one is on the home page and in the architecture document), and one directory is one place to look. Mermaid was not used: its automatic layout of a labelled, grouped diagram is hard to read and cannot be tuned. A page references the light file with anand a one-line caption; there is no other Markdown for it. - Two files, swapped by the site, not one adaptive file. The site's theme is a
darkclass on<html>set by a toggle, and an SVG shown by<img>cannot see that class; its ownprefers-color-schemefollows the operating system, which can disagree with the toggle. One SVG with a transparent background and colours that suit both themes would need mid-tone fills and text, which is readable in neither. So each diagram is exported twice from one model whose colours arelight-dark()pairs (draw.io 32 reads them, and--theme light|darkpicks the side), and a small remark step (remarkDocsLinks,site/plugins/docs.mjs) turns the image of a*.light.drawio.svginto two images, the light onedark:hiddenand the dark onehidden dark:block, each linked to its SVG to open at full size. Both carry a panel (a rounded card in the page's own background colour and a hairline border), so a diagram does not depend on what is behind it. On GitHub the light file shows in both of its themes, as a white card, which is readable;<picture>was not used because raw HTML in the Markdown would need a second rewriting of paths for the site and for the copied Markdown. - The site serves the images.
resolveLinkturns a relative reference to an SVG or PNG indocs/into a site URL, and the endpointssrc/pages/[...slug].svg.tsand.png.tsemit those files at the same path under the version's base, so a tag's docs carry the images they were tagged with. In the copied Markdown (absoluteMarkdown) the reference becomes the site URL of the image, not a GitHubblobpage that would not render. A missing image fails the build in the remark step, andcheck-links.mjsnow readssrcas well ashref. - Export.
make docs-diagrams(hack/docs-diagrams.mjs) runsdrawio -x -f svg -e -u --theme light|darkover eachdocs/images/*.light.drawio.svg(or a plainNAME.drawio, for a new diagram) underxvfb-runwhen there is no display, and writes the pair; the embedded model is uncompressed (-u) so a change reads in a diff, and the output is the same on a second run.drawioandxvfb-runare inflake.nix. draw.io adds a PNG of every label as a fallback for viewers withoutforeignObject, which takes a diagram from about 50 KB to 700 KB; the export removes them, since browsers and GitHub draw the live text (a viewer that does not, such as an image converter, shows no labels). - Checked.
make docs-checkopens the page of every diagram at desktop and phone width in both themes, checks that both images are there, exactly one is visible and it loaded, that the page has alt text, a caption and no sideways scroll, and takes a screenshot of each (<size>-<theme>-diagram-<name>.png).
Consequences: a diagram is edited in draw.io on the light file and exported again with one command; the dark file is never edited by hand and can drift only if someone forgets to export (a review sees the pair in one diff). The palette (neutral greys, indigo for Ichor's parts and a person's actions, amber for waiting, green for done, red for no way back) is kept by copying shapes, not enforced. The labels are HTML, drawn with the viewer's own font (Helvetica where present), so a diagram is laid out with room to spare for a wider fallback font. On a phone the diagram is scaled down to the column and is read by opening its SVG.