Skip to content
sign in

==> read the decisions

ADR-0026: The docs host renders three kinds of document, each on one schedule, and both API references through one renderer

Status: Accepted (2026-10-16, operator). Implemented 2026-10-16. Amends ADR-0022: the API references are rendered by the platform's own renderer inside Starlight, not by starlight-openapi; a project's reference is one page per operation; the docs' per-request pages are English and resolved on the server. Amends ADR-0025: the static docs show sign-out, because a signed-in reader holds a docs session on every docs page.

The docs host (ADR-0022) shows three kinds of document, and each grew its own way of being rendered:

. the platform's API reference -- prerendered by starlight-openapi from the build's document, one page per operation; . the reader's own projects' references -- rendered per request by the web server, in Starlight's layout, by a hand-written renderer (components/docs/ProjectReference.astro) as one long page; . the guides and the decisions -- prerendered Starlight content.

Around them, the static pages are patched in the browser: the theme and the bar before paint (accepted by ADR-0022 and ADR-0025), and also a sidebar that asks /api/me and clones a template into Starlight's markup at a place it finds by the text "api reference", and a sign-out button that appears after paint. The operator's verdict: "a gigantic mess". Checked on 2026-10-16 in the code, in dev (./dev.sh, signed in with a passkey) and in a production build of the current tree:

  • Two references that look and behave differently. The platform's has method badges, a request snippet, per-operation pages, a table of contents and pagination; a project's is raw JSON Schema dumps under <h2> headings, 7,500 px tall for five operations, its table of contents empty ("Overview" only), its descriptions' Markdown shown as source, its path parameters, refusals and WebSockets (x-cs-sockets, which both projects in dev have) left out.
  • Neither renderer can do the other's job. starlight-openapi reads its documents once, when the configuration loads, into a virtual module; even its on-demand route renders only those. It exports no components. A request-time document cannot go through it, and importing its internals by path was rejected.
  • The reader's projects are a browser patch on static pages and are missing from the sidebar of the very pages that are rendered per request with the reader known (me is resolved, the sidebar still waits for a script that needs the hint cookie).
  • The hint and the docs session disagree. The cs-account hint says who is signed in on the apex; the docs' own session is a separate, host-only cookie that exists only once the reader has opened a /projects page. A static page can say "signed in" with no docs session behind it; a sign-out there (POST /api/logout on the docs door) has no provider session to end and only clears the hint, leaving the reader signed in everywhere else.
  • The per-request pages follow the app's preferences ladder, not the docs': the bar comes out in Russian for a reader whose account says Russian, under <html lang="en">, beside English prose; the theme's stylesheet is the default one and a script swaps it, though the server knew the theme.
  • Small breakages with one cause -- nobody owns the docs as a whole: no not-found page on the docs host (a bare not found from the web server, an empty 404 from the edge); -- in the guides and decisions printed as an en dash (Markdown's smartypants); llms.txt points at an unversioned URL that answers 404; no eyebrow on any docs page; on a phone, Starlight's menu button covers the bar's controls; dev keeps serving the API document and the decisions it read at start.

The fundamental constraints:

  • Search needs files. Pagefind indexes the HTML the build writes; Starlight refuses Pagefind when its pages are not prerendered. So every public page is prerendered, and nothing per-reader is.
  • A reader's projects are private and change with each of their releases, on their own schedule, not the platform's build.
  • The edge serves the static pages without knowing who is reading, so anything personal on them arrives in the browser: before paint from a cookie a script can read, or after paint from a request.
  • A sign-out must end the provider's session, and only a host's own session cookie can name that session to the API (the identity host's cookie is its own; the docs cannot send it).
  • Every document the docs show is made by one function, openapi() in the shared library (ADR-0021): the platform's and each project's are the same small dialect of OpenAPI 3.1 (bearer security, JSON Schema from Zod, { error, code? } refusals, x-cs-sockets).
  • Starlight supports what a request-time page needs: StarlightPage on an on-demand route takes a sidebar, headings and frontmatter, and computes the sidebar's current item, the pagination and the table of contents itself; its Badge, Code, Tabs and Aside are public; a sidebar link takes attrs. A prerendered StarlightPage with getStaticPaths is indexed by Pagefind like any content page (both measured in a scratch build; a warm on-demand render of a small page took 4-7 ms).
  • Both API references are rendered by one renderer of ours, inside Starlight: a pure model of a document (operations grouped by tag, each with its parameters, body, answers, refusals, request snippet, and the document's sockets) and a few Astro components that draw it with Starlight's public components and the shared theme. It covers the dialect openapi() writes, not OpenAPI at large. starlight-openapi goes.
  • Same pages, different schedules. The platform's reference is prerendered from the build's document (/reference/api/ and /reference/api/<operation>/), indexed by search. A project's is rendered per request from its live release's document (/projects/<slug>/ and /projects/<slug>/<operation>/), never indexed, private, no-store. Both are StarlightPages, so a project's operation looks, navigates and paginates exactly like the platform's.
  • An operation's address is its method and path, minus the document's base: get-me, post-llm-chat-completions; in a project, get-jobs, and ws-status for a socket. The base (the door's origin and /v1) is the renderer's input, so the request snippet is right for both.
  • User text is sanitised: a project's descriptions are Markdown rendered without raw HTML and through an allow-list; the platform's go the same way, so nothing is trusted by kind.

A signed-in reader sees the same docs on every page

Section titled “A signed-in reader sees the same docs on every page”

The operator's rule: no difference between the static pages and the per-request pages.

  • Signed in on the apex means signed in to the docs. When a static page finds the hint and no docs session, it signs the docs in silently before it is shown: its pre-paint script replaces the page with /api/auth/login?silent=1&return=<this page>, which asks the provider with prompt=none. With a provider session every hop is a redirect, and the reader lands back on the page with a docs session. Without one, the provider answers login_required; the callback clears the stale hint and returns to the page, signed out. This path never shows a sign-in form. The docs mark that they hold a session in a script-readable cookie (cs-docs, host-only, no credential in it), so the script decides before paint; a tab tries at most once.
  • The bar is the same on both kinds of page: book, terminal, chip, cog, power. On a static page it is built signed out and switched before paint from the hint, power included.
  • Power always ends the provider's session. It posts to the docs' /api/logout, which ends the provider session the docs session names and says whether it did (ended). In the rare case there is none (the silent sign-in failed while the hint stayed), the bar sends the reader to the identity host's account page, whose sign-out ends it, instead of pretending.
  • The sidebar is Starlight's, built from one model (docs/sidebar.mjs): Guides, API reference, Your projects, Decisions. Per-request pages get it whole from the server through StarlightPage's sidebar, the open project expanded into its operations. Static pages are built with the "Your projects" group holding one link to /projects/, marked by a sidebar attrs attribute (data-cs-projects); the per-request sidebar carries the same link and mark. A small module fetches /projects/ -- the reader's own per-request page, whose sidebar the server built from the same model -- and swaps its marked group for the static one. No text matching, no sidebar markup built in the browser, no endpoint of its own. The tab keeps the last group (keyed by user and build), so from the second page on the list is in place before paint; only the first static page of a session fills it after paint.
  • Per-request pages are resolved on the server: the reader, the theme's stylesheet, the bar. All docs pages are English: the docs have one language (ADR-0025), whatever the account prefers.
  • A docs not-found page in Starlight's layout, prerendered for the edge to serve on a 404 and drawn by the per-request pages for a project or operation that is not there.
  • The text is printed as written: smartypants off (the house style is ASCII, -- is a dash).
  • Every docs page opens with one eyebrow (AGENTS.md), through Starlight's PageTitle override, from the dictionary: one per section.
  • On a phone the menu button belongs to the bar, through Starlight's MobileMenuToggle override.
  • Dev renders what the build would: the decisions and the API document are regenerated when their sources change, not only when astro dev starts.
  • One look for every reference, by construction; a project's reference becomes as usable as the platform's (per-operation pages, contents, pagination, sockets, refusals, path parameters).
  • The platform's reference loses what starlight-openapi drew and the dialect never uses: the snippet-language picker (the docs promise a curl with a bearer token, ADR-0022), callbacks, webhooks, examples, the tag overview pages. Its addresses change from operations/getme/ to get-me/; only /reference/api/ is linked from anywhere, and it stays.
  • About 600 lines of renderer to own, tested against the platform's document and fixtures in the dialect; a change to openapi() that adds a construct needs one here.
  • The first docs page a signed-in reader opens in a browser session costs a round of redirects through the identity host before it is shown (silent, no form), and its "Your projects" list fills in after paint. Every later page is complete before paint.
  • The API learns a silent sign-in (prompt=none, answered login_required by the identity host's pages through the provider), and its /api/logout says whether it ended a session.
  • A signed-in static page fetches one per-request page (/projects/) for its sidebar, once per page view; the markup it takes is Starlight's own from the same build, and the kept copy is keyed by the build, so a deploy never mixes the two.
  • Deleted: starlight-openapi, ProjectReference.astro, the template-cloning components/docs/Sidebar.astro, lib/docs-me.ts, the Header's after-paint /api/me script.
  • The edge (nixos2) serves the docs' not-found page on a 404.
  • Keep starlight-openapi for the platform and write a second renderer for projects in its look. Two renderers of one dialect, one of them imitating the other's markup and classes; they drift on the first upgrade, and the platform's reference still drops sockets.
  • A copy of starlight-openapi's components (MIT, about 5,900 lines with its libraries), fed request-time documents. The rejected import by path, owned instead of imported: a fork ten times the size of what the dialect needs, still bound to that package's markup and virtual modules.
  • Each user's release builds its own Starlight site (the plugin as intended) and the docs host serves it behind the sign-in. Astro, Starlight and their 380 MB of dependencies in every release build; the bar, theme and sidebar frozen at the release's version until the user pushes again; no shared sidebar or search across them.
  • Every docs page rendered per request (Starlight's prerender: false). Starlight refuses Pagefind then; search would need a service of its own.
  • Scalar for the projects. A second look, rejected by ADR-0022 already.
  • Building the projects' sidebar entries in the browser from /api/me. A second renderer of Starlight's sidebar markup, in a script, that breaks when Starlight changes it; the server already has the model and the component.
  • A sign-out that falls back to clearing the hint. The reader would look signed out here and stay signed in everywhere else.
  • A sign-out by visiting a URL (a GET on any host). Any page could sign the reader out with a link or an image.
  • A docs session only from the first visit to /projects (today). The static pages would keep a bar and a sidebar that promise a session nobody has.