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

  1. Astro, static, under a base. site/ is an Astro 7 project (scaffolded with npm create astro@latest, astro add preact and astro add tailwind) with static output. site and base default to https://ichor.maesh.dev and / and are set by DOCS_SITE and DOCS_BASE for a fork or another domain. make docs-site builds it (npm ci, astro build, the link check, the Pagefind index) into site/dist; make docs-dev is the live preview, make docs-preview serves the build, make docs-check also drives it in headless Chromium (hack/docs-shots.mjs: search, hideable sidebar, links, phone width, screenshots). .github/workflows/docs.yml builds on a push to main that touches docs/**, site/** or CHANGELOG.md (and by hand) and deploys with actions/upload-pages-artifact and actions/deploy-pages, pinned by commit SHA, with pages: write and id-token: write on the deploy job only. The version badge is the newest heading of CHANGELOG.md (DOCS_VERSION overrides it).
  2. The Markdown is read in place. One content collection, docs, with the glob() loader over ../docs (ids are the paths without .md; README.md and index.md are their directory's page). Front matter is optional and only title, description, sidebar_label and sidebar_order mean anything; the title otherwise is the first # heading. The sidebar is generated from the directory tree: the directory is the section, pages are ordered by sidebar_order then 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). If docs/index.md does not exist, the home page is generated from the sections. docs/contributing-docs.md is the author's guide.
  3. 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 or hName node data, which the link rewriting and the callouts need, so @astrojs/markdown-remark is installed and unified() is the processor, with three small remark plugins (site/plugins/docs.mjs) and smartypants: false (GitHub does not turn --flag into a dash).
    • Relative links between .md files, with their #anchors, become site URLs; relative links that leave docs/ become GitHub links to the file or directory; a link to something that does not exist fails the build. After the build site/scripts/check-links.mjs checks every internal href and #heading of 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 preactcn Alert (Callout.tsx, applied by Prose.astro; WARNING and CAUTION use its destructive variant).
    • Code blocks are Astro's Shiki output with the two themes as CSS variables; the copy button is added to every pre by the CodeCopy island, not by authors.
  4. preactcn, as documented. site/components.json mirrors ui/components.json (registry @preactcn); components came only from npx 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 from shadcn 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 in localStorage, a Sheet below md), 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 from xl, a collapsible at the top of the article below). Only layout utilities are ours. The one addition is the @tailwindcss/typography plugin for the Markdown body, as it has no preactcn counterpart.
  5. Pagefind, with our own dialog. pagefind --site dist runs after the Astro build (npm run build), rather than the astro-pagefind integration, which would hide the order of the link check and the index and add a dependency for one command. The article is data-pagefind-body; breadcrumbs, the page list and the previous/next links are data-pagefind-ignore; the title has weight 10. The UI is the preactcn Command in a dialog (SearchDialog.tsx) calling pagefind.debouncedSearch and result.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-dev has no index (the dialog says so).
  6. The ADR index links to the ADRs. internal/tools/adrindex writes 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/preact brought 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, description and sidebar_order on 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.

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.

  1. Page actions. The PageActions island sits at the top right of the title: a preactcn ButtonGroup with "Copy page" (copies the Markdown) and a DropdownMenu with "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 preactcn Sonner toast (added with npx shadcn@latest add @preactcn/button-group @preactcn/sonner). The clipboard write goes through a ClipboardItem holding the pending fetch of the .md, which Safari needs to keep the user gesture.
  2. Markdown routes. An Astro endpoint, src/pages/[...slug].md.ts, emits <route>.md (index.md for the home page) for every entry of the docs collection 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 adopt is promoted), and has every relative link made absolute by absoluteMarkdown (site/plugins/docs.mjs), which shares resolveLink with the remark plugin: links to pages become site URLs, links out of docs/ become GitHub URLs, #anchors point at the page; fenced code and inline code are left alone and a missing target fails the build as before. /llms.txt lists the pages by sidebar section with their descriptions and links to the .md files, /llms-full.txt concatenates all of them in sidebar order. Pagefind only reads .html files, so none of these are indexed (checked: the page count is unchanged); check-links.mjs also reads only HTML.
  3. Heading links. A rehype step in Prose.astro appends a link to itself (a stock ghost icon-xs Button's classes, the lucide Link icon, aria-label naming 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, and PageActions copies its URL on click. Headings also get scroll-mt-20 for the sticky header.
  4. Theme icon. The toggle renders both icons and CSS shows the one for the dark class set by the head script (hidden dark:block, dark:hidden), so the first paint is right.
  5. Versions come from git, not copies. One build is one version: DOCS_VERSION is next (default, the working tree's docs/) or a release tag vX.Y.Z, built at <DOCS_BASE>/<version>/ (DOCS_BASE, default /ichor, is where all versions sit). For a tag, site/scripts/build-all.mjs exports the tag with git archive into a temporary directory and sets DOCS_REPO, which replaces the repository root, so docs/ and the files that links point out to are those of the tag. Every version is built with the current site/ (a tag's own site/ 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 .md and llms routes and the Pagefind index (<version>/pagefind/, one per version, so search covers the version being read) follow the version; GitHub links out of docs/ 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, never next. The make docs-site build stays next only; make docs-site-all builds every version into site/dist-all, with versions.json, a redirect page under latest/ for every page of the newest release (/latest/… and the site root go to it; to next when there is no release) and its llms.txt files at the root. make docs-check builds all and drives them.
  6. Switcher and banner. versions.json at the site root ([{name, path, isLatest, isNext}], next first, then the releases by semver, newest first) is written by the build from git tag -l 'v*' (only vX.Y.Z) and fetched at runtime by the VersionSwitcher (a DropdownMenu with radio items, replacing the static version badge, with next (main) and vX.Y.Z (latest) labels), so an old build lists later releases. Choosing a version goes to the same page there if a HEAD request finds it, else to that version's overview. The VersionBanner (the preactcn Alert) says "Unreleased (main)" on next and "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.
  7. Publishing. .github/workflows/docs.yml runs on a push to main (docs, site or the workflow) and on a v* tag, always checks out main with all tags (a tag run must not build with the tag's site/), runs make docs-site-all and deploys site/dist-all, so a new tag adds its docs and its entry to the switcher of every version. The CHANGELOG.md trigger and the badge read from it are gone; the DOCS_VERSION variable 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.

  1. 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 in docs/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 an ![alt](…) and a one-line caption; there is no other Markdown for it.
  2. Two files, swapped by the site, not one adaptive file. The site's theme is a dark class on <html> set by a toggle, and an SVG shown by <img> cannot see that class; its own prefers-color-scheme follows 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 are light-dark() pairs (draw.io 32 reads them, and --theme light|dark picks the side), and a small remark step (remarkDocsLinks, site/plugins/docs.mjs) turns the image of a *.light.drawio.svg into two images, the light one dark:hidden and the dark one hidden 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.
  3. The site serves the images. resolveLink turns a relative reference to an SVG or PNG in docs/ into a site URL, and the endpoints src/pages/[...slug].svg.ts and .png.ts emit 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 GitHub blob page that would not render. A missing image fails the build in the remark step, and check-links.mjs now reads src as well as href.
  4. Export. make docs-diagrams (hack/docs-diagrams.mjs) runs drawio -x -f svg -e -u --theme light|dark over each docs/images/*.light.drawio.svg (or a plain NAME.drawio, for a new diagram) under xvfb-run when 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. drawio and xvfb-run are in flake.nix. draw.io adds a PNG of every label as a fallback for viewers without foreignObject, 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).
  5. Checked. make docs-check opens 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.

Search the docs

Search pages and headings