==> 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.
Context
Section titled “Context”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-openapireads 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 (
meis resolved, the sidebar still waits for a script that needs the hint cookie). - The hint and the docs session disagree. The
cs-accounthint 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/projectspage. A static page can say "signed in" with no docs session behind it; a sign-out there (POST /api/logouton 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 foundfrom the web server, an empty 404 from the edge);--in the guides and decisions printed as an en dash (Markdown's smartypants);llms.txtpoints 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:
StarlightPageon an on-demand route takes asidebar,headingsandfrontmatter, and computes the sidebar's current item, the pagination and the table of contents itself; itsBadge,Code,TabsandAsideare public; a sidebar link takesattrs. A prerenderedStarlightPagewithgetStaticPathsis 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).
Decision
Section titled “Decision”One renderer, two schedules
Section titled “One renderer, two schedules”- 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-openapigoes. - 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 areStarlightPages, 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, andws-statusfor 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 withprompt=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 answerslogin_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 throughStarlightPage'ssidebar, the open project expanded into its operations. Static pages are built with the "Your projects" group holding one link to/projects/, marked by a sidebarattrsattribute (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.
The rest of the host
Section titled “The rest of the host”- 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
PageTitleoverride, from the dictionary: one per section. - On a phone the menu button belongs to the bar, through Starlight's
MobileMenuToggleoverride. - Dev renders what the build would: the decisions and the API document are regenerated when their
sources change, not only when
astro devstarts.
Consequences
Section titled “Consequences”- 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-openapidrew and the dialect never uses: the snippet-language picker (the docs promise acurlwith a bearer token, ADR-0022), callbacks, webhooks, examples, the tag overview pages. Its addresses change fromoperations/getme/toget-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, answeredlogin_requiredby the identity host's pages through the provider), and its/api/logoutsays 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-cloningcomponents/docs/Sidebar.astro,lib/docs-me.ts, the Header's after-paint/api/mescript. - The edge (nixos2) serves the docs' not-found page on a 404.
Alternatives rejected
Section titled “Alternatives rejected”- Keep
starlight-openapifor 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.