# 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.

## 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.

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.
