From 2f375fbc2b00af417c708551596c0e9d8b3047f2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 3 Nov 2026 10:57:01 +0000 Subject: [PATCH 1/3] feat(docs): publish live and prerelease documentation channels Aggregate each project's documentation from up to two git refs (ADR 0009): a `live` channel for the latest non-prerelease release, served at the canonical /docs// URLs, and a `prerelease` channel served at /docs//prerelease/. - manifest `docs.channels` with ref tokens (`default`, `latest-stable`, `latest-prerelease`, or an explicit branch/tag) and optional per-channel layout overrides; omitted `channels` keeps the single-channel behaviour - docs cache schema 2 (`projects[].channels[]`); the mirror gains a channel directory for non-default channels and every consumer walks it - sidebar groups per channel, in-page switcher and prerelease notice; tag-pinned pages link to the file at that tag and are not flagged stale - promotion is automatic: a prerelease channel is withheld once a stable GitHub release or NuGet version catches up with the prerelease line, and returns by itself with the next prerelease - retired channel URLs redirect to the live page with the same slug (static meta refresh, canonical, noindex, kept out of the sitemap, discover.json, llms bundles and the search index) - catalogue guard: channel blockers/advisories, plus doc mirror and discovery checks that understand channel directories - ZodSharp: live documentation pinned to v2.4.2, prerelease from main --- .../skills/docs-aggregation-rules/SKILL.md | 59 ++- .../project-manifest-reference/SKILL.md | 44 +- AGENTS.md | 8 + README.md | 31 ++ docs/decisions/0009-documentation-channels.md | 165 +++++++ src/astro.config.ts | 5 + src/config/docs-redirects-integration.ts | 41 ++ src/scripts/check-generated.ts | 113 ++--- src/scripts/check-projects.ts | 130 +++++- src/scripts/data-sync.ts | 13 +- src/scripts/sync-docs.ts | 23 +- src/src/components/starlight/EditLink.astro | 10 +- src/src/components/starlight/PageTitle.astro | 109 ++++- src/src/content.config.ts | 7 + src/src/data/projects/zodsharp.yml | 12 + src/src/lib/discovery/emit.ts | 24 +- src/src/lib/discovery/project-llms.ts | 5 + src/src/lib/discovery/resources.ts | 34 +- src/src/lib/docs/aggregate.ts | 317 ++++++++++---- src/src/lib/docs/channels.ts | 411 ++++++++++++++++++ src/src/lib/docs/frontmatter.ts | 21 + src/src/lib/docs/redirects.ts | 138 ++++++ src/src/lib/docs/sidebar.ts | 90 +++- src/src/lib/manifest/schema.ts | 66 ++- src/src/pages/docs/index.astro | 10 +- src/tests/dist/built-output.test.ts | 48 ++ .../discovery/discovery-resources.test.ts | 47 +- src/tests/unit/docs-channels.test.ts | 331 ++++++++++++++ src/tests/unit/docs-redirects.test.ts | 117 +++++ 29 files changed, 2227 insertions(+), 202 deletions(-) create mode 100644 docs/decisions/0009-documentation-channels.md create mode 100644 src/config/docs-redirects-integration.ts create mode 100644 src/src/lib/docs/channels.ts create mode 100644 src/src/lib/docs/redirects.ts create mode 100644 src/tests/unit/docs-channels.test.ts create mode 100644 src/tests/unit/docs-redirects.test.ts diff --git a/.agents/skills/docs-aggregation-rules/SKILL.md b/.agents/skills/docs-aggregation-rules/SKILL.md index 7037428..ac78d1c 100644 --- a/.agents/skills/docs-aggregation-rules/SKILL.md +++ b/.agents/skills/docs-aggregation-rules/SKILL.md @@ -24,12 +24,61 @@ and records the page list in `src/.cache/docs/index.json`. | `docs.source` | Fetch mechanism | Notes | | --- | --- | --- | -| `github-path` | Repository tree (`git/trees/main?recursive=1`) filtered to `/**/*.md` | Each file fetched from `raw.githubusercontent.com` on branch `main` | -| `wiki` | `git clone --depth 1 https://github.com/.wiki.git` | `_Sidebar.md` and `_Footer.md` are never rendered | -| `readme` | Raw `README.md` | Exactly one page (`index`) | +| `github-path` | Repository tree (`git/trees/?recursive=1`) filtered to `/**/*.md` | Each file fetched from `raw.githubusercontent.com` at the channel's ref (the repository's default branch when no `channels` are declared) | +| `wiki` | `git clone --depth 1 https://github.com/.wiki.git` | `_Sidebar.md` and `_Footer.md` are never rendered. A wiki has one HEAD, so it supports a single channel | +| `readme` | Raw `README.md` | Exactly one page (`index`), single channel | + +The ref is the repository's **default branch** (from the release cache, falling +back to `main`) unless the manifest declares `docs.channels` (ADR 0009). +`lastReviewed` is the last commit date for the file (Atom feed for +`github-path`/`readme`, wiki HEAD commit for `wiki`); a channel pinned to a +release tag uses that release's date instead, because the content cannot move. + +## Channels (`live` and `prerelease`) + +A project may publish two views of its documentation: + +```yaml + docs: + source: github-path + path: docs/wiki + rootPage: Getting-Started.md + channels: + live: + ref: latest-stable + prerelease: + ref: default +``` + +- The **default** channel (`live`, or the only declared channel) is written to + `src/src/content/docs/docs//.md` and served at `/docs///`. +- Any other channel is written to + `src/src/content/docs/docs///.md` and served at + `/docs////`. +- `docs//` is reserved: a source page whose slug equals a channel id + raises `DocsValidationError`. +- `src/.cache/docs/index.json` is schema 2 and stores + `projects[].channels[] = { id, ref, refKind, version, label, default, pages[] }`. + A cache from an older schema reads as absent, so `just data-sync` regenerates it. +- The Starlight sidebar groups a two-channel project per channel (`Live 2.4.2`, + `Prerelease main`); a one-channel project keeps a flat list. The page title + component renders the switcher and a prerelease notice. +- **Promotion is automatic.** The prerelease channel is only aggregated while a + prerelease version outranks the newest stable release, computed from GitHub + release tags and NuGet versions. A stable release that catches up retires the + channel on the next sync (a `docs : prerelease documentation is not + published: …` line, not an error) and the next prerelease restores it. Never + remove the declaration just to stop publishing it. +- **Retired URLs redirect to live.** For each withheld channel URL the build + writes a `noindex` meta-refresh page (canonical + `data-pagefind-ignore`) + pointing at the live page with the same slug — GitHub Pages serves files only, + so `_redirects` is not an option. These files are written at + `astro:build:done` (`config/docs-redirects-integration.ts`), outside Astro's + route list, so they stay out of the sitemap, `discover.json` and the llms + bundles. +- Per-project `llms-full.txt` publishes the default channel only; `llms.txt` + also links the prerelease channel when one exists. -Branch is always **`main`**. `lastReviewed` is the last commit date for the file (Atom feed for -`github-path`/`readme`, wiki HEAD commit for `wiki`). ## Root page selection (the most common onboarding failure) diff --git a/.agents/skills/project-manifest-reference/SKILL.md b/.agents/skills/project-manifest-reference/SKILL.md index 7bcac90..d2bd5cd 100644 --- a/.agents/skills/project-manifest-reference/SKILL.md +++ b/.agents/skills/project-manifest-reference/SKILL.md @@ -62,9 +62,9 @@ Adding a value to any of these lists is a schema change and requires an ADR. `ex | `source` | Fields | Behaviour | | --- | --- | --- | -| `github-path` | `path` (required), plus `rootPage`, `readmeAsIndex`, `order`, `exclude` | Walks `/**/*.md` on branch `main` | -| `wiki` | `rootPage`, `readmeAsIndex`, `order`, `exclude` | Shallow-clones `.wiki.git`; `_Sidebar.md` orders pages, `_Footer.md` is skipped | -| `readme` | none | Publishes `README.md` alone as `/docs//` | +| `github-path` | `path` (required), plus `rootPage`, `readmeAsIndex`, `order`, `exclude`, `channels` | Walks `/**/*.md` at each channel's ref (default branch when no `channels`) | +| `wiki` | `rootPage`, `readmeAsIndex`, `order`, `exclude` | Shallow-clones `.wiki.git`; `_Sidebar.md` orders pages, `_Footer.md` is skipped. Single channel only | +| `readme` | none | Publishes `README.md` alone as `/docs//`. Single channel only | - `rootPage` names the markdown file that becomes the landing page (`index`). It may only select a non-index page when the conventional root page is removed via `exclude` (the aggregation otherwise @@ -75,6 +75,44 @@ Adding a value to any of these lists is a schema change and requires an ADR. `ex - `order` is an explicit list of page slugs, applied before the `_Sidebar.md` order. - If `docs` is set but the aggregation yields no pages, `just data-sync` fails loudly. +## `docs.channels` — live and prerelease documentation (ADR 0009) + +A project whose default branch is ahead of its latest release can publish two +views of its documentation. Channels are optional; omitting them keeps one +`live` channel on the default branch. + +```yaml +docs: + source: github-path + path: docs/wiki + rootPage: Getting-Started.md + channels: + live: + ref: latest-stable # newest non-prerelease release tag + prerelease: + ref: default # the repository's default branch +``` + +| Field | Meaning | +| --- | --- | +| `channels.live` / `channels.prerelease` | At most these two; unknown keys are rejected | +| `ref` | `default` (default branch), `latest-stable`, `latest-prerelease`, or any branch/tag/commit used verbatim. Defaults: `live` → `latest-stable`, `prerelease` → `default` | +| `path`, `rootPage`, `readmeAsIndex`, `order`, `exclude` | Optional per-channel overrides of the shared block (a tag's docs tree can differ from `main`) | + +- **`live` is the default channel** and owns `/docs//…`; `prerelease` is + served at `/docs//prerelease/…`. A prerelease-only project is the default + channel and serves its docs at the canonical URLs. +- `latest-stable` with no stable release → live follows the default branch + (warning). `latest-prerelease` with no tag → the channel is skipped. Two + channels on the same ref → the non-default one is dropped. +- **Promotion is automatic.** The prerelease channel is published only while a + prerelease version outranks the newest stable release, comparing GitHub + release tags with NuGet versions. Publishing `3.0.0` retires + `3.0.0-prerelease.N` docs on the next build; the channel returns with the next + prerelease. No manifest edit or PR is needed to stop publishing it. +- `wiki` and `readme` sources reject `channels`: they have a single version. +- Use-case `docsPage` deep links resolve against the **default** channel. + ## Loader-enforced rules (`loadProjects()`) 1. Schema validation with remediation text (unknown keys are rejected). diff --git a/AGENTS.md b/AGENTS.md index 91aa985..c007a1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,14 @@ prints advisory observations (which never fail the build) when a project's decla - Every non-archived project needs at least one concrete use case; a use case with `code` must also set `language`, and `docsPage` must be a lowercase `[a-z0-9-]` slug that resolves to a real aggregated page. + - Documentation channels are optional (ADR 0009): `docs.channels.live` / `docs.channels.prerelease` + pin a project's live and prerelease documentation to refs (`default`, `latest-stable`, + `latest-prerelease`, or an explicit branch/tag). The default channel owns `/docs//`, the + other is served at `/docs///`, and `docsPage` resolves against the default channel. + Only `github-path` supports two channels; `wiki`/`readme` have a single version. Promotion is + automatic: the prerelease channel is withheld once a stable release (GitHub release or NuGet + version) catches up with the prerelease line, and returns with the next prerelease — never + delete the declaration to stop publishing it. - `order` values are unique and control the catalogue order. - `related`, `supersedes`, `supersededBy` must reference known ids. - `status` must match reality: an archived repository is `archived`; prerelease-only tooling is diff --git a/README.md b/README.md index 85012c7..f873bf0 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,37 @@ Each documentation page shows its owning project, lifecycle status, staleness (last review older than 365 days, configurable in `src/lib/docs/staleness.ts`), and an edit link to the source repository. +### Live and prerelease documentation + +A project whose default branch is ahead of its latest release can publish two +views of its docs (ADR 0009): + +```yaml +docs: + source: github-path + path: docs/wiki + channels: + live: + ref: latest-stable # newest non-prerelease release tag + prerelease: + ref: default # the repository's default branch +``` + +The `live` channel is the default and keeps the canonical `/docs//` +URLs; the `prerelease` channel is served at `/docs//prerelease/`. The +sidebar groups pages per channel, and every page offers a switcher. Only +`github-path` sources support two channels. + +**Promotion is automatic.** The build publishes the prerelease channel only +while a prerelease version outranks the newest stable release — comparing GitHub +release tags with NuGet versions — so publishing `3.0.0` retires the docs for +`3.0.0-prerelease.N` on the next deploy with no manifest change. The channel +returns by itself when the next prerelease is cut. Nothing here requires a +clone-and-PR step before a deploy. Retired URLs are not left broken: the build +writes a `noindex` meta-refresh page for each one that points at the live page +with the same slug (GitHub Pages serves files only, so there is no redirect +config to declare). + ## Release data Release data is fetched at build time — never from the visitor's browser. diff --git a/docs/decisions/0009-documentation-channels.md b/docs/decisions/0009-documentation-channels.md new file mode 100644 index 0000000..0444891 --- /dev/null +++ b/docs/decisions/0009-documentation-channels.md @@ -0,0 +1,165 @@ +# 0009 — Documentation channels (`live` and `prerelease`) + +- **Status:** Accepted +- **Date:** 2026-03-11 +- **Supersedes:** none + +## Context + +The documentation portal aggregates each project's documentation from the +repository that owns it. Until now every page came from branch `main`, which is +only correct while `main` describes the most recently released version. + +That is no longer true for every project. ZodSharp is the clearest example: the +latest published release is `v2.4.2`, while `main` already carries the +`v3.0.0-prerelease.N` line. A reader who installs `2.4.2` and reads +`/docs/zodsharp/` is being shown API that does not exist in their package. The +same applies to any project that ships fixes on a release branch while `main` +moves on. + +The obvious workaround — pin every project's docs to a tag — is wrong in the +other direction: a project with no stable release (a preview-only tool) must +publish from `main`, and even a released project's readers benefit from seeing +what is coming. The portal needs **two views**, and it needs to know which one +is canonical. + +## Decision + +A project may publish **up to two documentation channels**: + +| Channel | Content | URLs | +| --- | --- | --- | +| `live` | The latest **non-prerelease** release — what a reader can install today | `/docs//…` (canonical) | +| `prerelease` | The line that has not shipped yet | `/docs//prerelease/…` | + +### 1. The default channel owns the existing URLs + +The default channel is `live` when it is declared, otherwise the only declared +channel. It is served at `/docs//…` exactly as before, so no existing URL +moves: use-case deep links (`docsPage`), the project page's Documentation link, +`llms.txt` entries, sitemaps, external inbound links, and the AGENTS invariant +that the display name slugifies to the project id all stay valid. A +prerelease-only project therefore serves its prerelease docs at the canonical +URLs — there is no second view to switch to. + +A non-default channel is addressed by its channel id as one path segment. The +segment is the channel, not the version: `/docs/zodsharp/prerelease/…` keeps +working as the prerelease moves from `3.0.0-prerelease.2` to `.3` to `3.0.0`, +and needs no redirects. The version is stated on the page, where it belongs. + +### 2. The manifest declares the intent; the sync resolves it + +"Is `main` a prerelease?" is not observable offline, and guessing would +silently publish the wrong documentation. The manifest therefore declares the +channels and the ref each one follows: + +```yaml +docs: + source: github-path + path: docs/wiki + rootPage: Getting-Started.md + channels: + live: + ref: latest-stable # token → newest non-prerelease release tag + prerelease: + ref: default # token → the repository's default branch +``` + +`ref` accepts `default`, `latest-stable`, `latest-prerelease`, or any +branch/tag/commit used verbatim. Resolution is deterministic against the +release cache (`lib/docs/channels.ts`), and a channel may restate `path`, +`rootPage`, `exclude`, `order` or `readmeAsIndex` because a tag's tree can +legitimately differ from `main`. + +Omitted `channels` keeps the historical behaviour: one `live` channel on the +default branch. That is the case for every project that has not declared +channels, and it produces no channel UI at all. + +### 3. Degradation is defined, not accidental + +- `latest-stable` with no stable release → the live channel follows the default + branch and the sync says so. +- `latest-prerelease` with no prerelease tag → the channel is skipped. +- A stable release that catches up with the prerelease line → the `prerelease` + channel is withheld automatically (see 4). The declaration stays, so the + channel returns with the next prerelease. +- Two channels resolving to the same ref → the non-default one is dropped, so a + promoted prerelease cannot publish a byte-identical second copy. +- A declared channel that yields no pages → skipped (with a warning); the + default channel is always kept, because a project must never lose its docs. + +### 4. Promotion is automatic + +Promotion is decided by the deploy build from published release data, never by a +manifest edit. A prerelease channel is published only while its line is **ahead** +of the newest stable release; `lib/docs/channels.ts#prereleaseLinePromoted` +computes that from the highest stable and prerelease versions across GitHub +release tags **and** NuGet package versions (a package can ship before its GitHub +release, and a repository can tag a release whose packages follow). When +`3.0.0` is published, `3.0.0-prerelease.2` no longer outranks it, so the channel +is withheld on the next build even though `docs.channels.prerelease` is still +declared. It returns by itself when the project cuts its next prerelease. + +This is what makes a GitHub-only deploy safe: nobody has to clone the site, +delete a line, and open a pull request to stop publishing documentation for a +version that shipped. `just check-projects` treats a withheld channel as an +observation ("not published: stable 3.0.0 has caught up with 3.0.0-prerelease.2; +nothing to do") rather than drift, and warns if a channel is published while its +line is already retired (a stale mirror — re-sync). + +### 5. Retired URLs redirect to the live release + +A withheld channel must not turn its pages into 404s for readers following a +bookmark. Because purview.dev is served from GitHub Pages (static files only, no +redirect configuration), the build writes a meta-refresh page for every withheld +channel URL — `/docs//prerelease/` and each +`/docs//prerelease//` — pointing at the live page with the same slug +(`lib/docs/redirects.ts`, run by `config/docs-redirects-integration.ts` at +`astro:build:done`). Each redirect carries `rel="canonical"`, `noindex, follow` +and `data-pagefind-ignore`, and is written outside Astro's route list so it never +enters the sitemap, `discover.json`, the llms bundles or the search index. The +redirect set is derived from the live channel's own slugs, so no generated +history has to survive between builds, and an existing page is never overwritten. + +## Consequences + +- `src/.cache/docs/index.json` is schema **2**: `projects[].channels[]` with + `{ id, ref, refKind, version, label, default, pages[] }`. Pages carry their + `sourcePath` and a content `hash`. An old cache reads as absent, so the build + forces a re-sync rather than linking pages the mirror no longer has. +- The mirror gains one directory level for non-default channels: + `src/content/docs/docs//prerelease/.md`. Every consumer that walked + one level (generated-output check, discovery `lastmod`) now walks two. +- Each aggregated page's front matter names its channel (`docsChannel`, + `docsRef`, `docsRefKind`, `docsVersion`). A tag-pinned page links to the file + at that tag ("View source") rather than offering an edit that cannot work, and + is not flagged stale: its content is the release, not drift. +- The sidebar shows one Starlight group per channel (`Live 2.4.2`, + `Prerelease main`); single-channel projects keep the flat list. +- The per-project `llms-full.txt` corpus publishes the live channel only, and + the concise `llms.txt` links the prerelease channel when it exists. Two + versions of one page in a single corpus is ambiguous to a model. +- `wiki` and `readme` sources support exactly one channel. A wiki is a + standalone git repository with a single HEAD, and a README is one page; the + schema rejects `channels` on them instead of accepting a declaration it cannot + honour. +- Search indexes both channels. Prerelease pages carry a channel chip and a + prose notice, so a result that came from the prerelease view says so. + +## Alternatives considered + +- **Version segments** (`/docs//2.4/`, `/docs//v3.0.0-prerelease.2/`). + Every release would add a URL set, every promotion would need redirects, and + the "latest" URL would still have to exist. Rejected in favour of a stable + channel segment plus a version label on the page. +- **Auto-detecting a prerelease `main`** by comparing the branch version file to + the release tags. The version file is not standardised across repositories, + the comparison is unavailable offline, and a wrong guess publishes + documentation for an API the reader does not have. Rejected in favour of an + explicit declaration resolved from release data. +- **A client-side toggle** on one URL set. It would hide the distinction from + search engines and from `llms.txt`, and make `/docs///` ambiguous. +- **Versioning the wiki source.** A repository wiki has no tags, so there is + nothing to pin. Documented as unsupported rather than half-implemented. +- **Keeping both channels in `llms-full.txt`.** Rejected: one corpus should not + contain two versions of the same page. diff --git a/src/astro.config.ts b/src/astro.config.ts index ad6b607..700e791 100644 --- a/src/astro.config.ts +++ b/src/astro.config.ts @@ -10,6 +10,7 @@ import starlightLlmsTxt from 'starlight-llms-txt'; import starlightSidebarTopics from 'starlight-sidebar-topics'; import discoveryIntegration from './config/discovery-integration'; +import docsRedirectsIntegration from './config/docs-redirects-integration'; import { remarkMermaid } from './config/remark-mermaid.mjs'; import { projectLlmsUrl } from './src/lib/discovery/urls'; import { readDocsManifest } from './src/lib/docs/aggregate'; @@ -124,6 +125,10 @@ export default defineConfig({ integrations: [ preact(), suppressStarlightSitemap, + // Registered before discovery so a retired channel's redirect pages exist + // before the sitemap/llms artifacts are derived (Astro's page list never + // includes them, so they stay out of every generated index). + docsRedirectsIntegration(), discoveryIntegration(), starlight({ title: SITE.fullName, diff --git a/src/config/docs-redirects-integration.ts b/src/config/docs-redirects-integration.ts new file mode 100644 index 0000000..ed0d4f1 --- /dev/null +++ b/src/config/docs-redirects-integration.ts @@ -0,0 +1,41 @@ +import type { AstroIntegration } from 'astro'; + +import { fileURLToPath } from 'node:url'; + +import { readDocsManifest } from '../src/lib/docs/aggregate'; +import { writeRetiredChannelRedirects } from '../src/lib/docs/redirects'; +import { loadProjects } from '../src/lib/manifest/load'; + +/** + * Redirects for retired documentation channels (ADR 0009). + * + * A project's `prerelease` channel is withheld automatically once a stable + * release catches up with it, so its URLs must not 404. purview.dev is served + * from GitHub Pages, which only serves files, so each retired URL is written as + * a meta-refresh page pointing at the live page with the same slug. + * + * Runs at `astro:build:done` beside the discovery integration. It deliberately + * writes outside Astro's route list, so the redirects never enter the sitemap, + * `discover.json` or the llms bundles, and they are marked `noindex` and + * `data-pagefind-ignore` so they never become search results. Existing files are + * never overwritten — a published channel owns its URLs. + */ +export default function docsRedirectsIntegration(): AstroIntegration { + return { + name: 'purview-docs-redirects', + hooks: { + 'astro:build:done': ({ dir, logger }) => { + const written = writeRetiredChannelRedirects( + fileURLToPath(dir), + loadProjects(), + readDocsManifest(), + ); + if (written.length > 0) { + logger.info( + `Redirects: ${written.length} retired documentation URL(s) now point at the live release.`, + ); + } + }, + }, + }; +} diff --git a/src/scripts/check-generated.ts b/src/scripts/check-generated.ts index cca86e8..51b4d57 100644 --- a/src/scripts/check-generated.ts +++ b/src/scripts/check-generated.ts @@ -80,67 +80,80 @@ function validateDocsMirror(): void { const expected = new Set(); for (const project of manifest.projects) { - for (const page of project.pages) { - expected.add(`${project.projectId}/${page.slug}`); - if (!existsSync(join(mirrorRoot, project.projectId, `${page.slug}.md`))) { - fail( - `Docs manifest lists a page with no mirror file: docs/${project.projectId}/${page.slug}. ` + - 'The Starlight sidebar would link to a missing page — re-run `just data-sync`.', - ); + for (const channel of project.channels) { + for (const page of channel.pages) { + // A non-default channel adds its id as a path segment: + // `docs///.md` versus `docs//.md`. + const relative = channel.default + ? `${project.projectId}/${page.slug}` + : `${project.projectId}/${channel.id}/${page.slug}`; + expected.add(relative); + if (!existsSync(`${join(mirrorRoot, ...relative.split('/'))}.md`)) { + fail( + `Docs manifest lists a page with no mirror file: docs/${relative}.md. ` + + 'The Starlight sidebar would link to a missing page — re-run `just data-sync`.', + ); + } } } } - for (const entry of readdirSync(mirrorRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - for (const page of readdirSync(join(mirrorRoot, entry.name))) { - if (page.endsWith('.md') && !expected.has(`${entry.name}/${page.slice(0, -3)}`)) { + const walk = (directory: string, prefix: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + walk(join(directory, entry.name), `${prefix}${entry.name}/`); + continue; + } + if (!entry.name.endsWith('.md')) { + continue; + } + const relative = `${prefix}${entry.name.slice(0, -3)}`; + if (!expected.has(relative)) { fail( - `Docs mirror page is missing from the manifest: docs/${entry.name}/${page}. ` + + `Docs mirror page is missing from the manifest: docs/${relative}.md. ` + 'Re-run `just data-sync` so the sidebar and content agree.', ); } - if (!page.endsWith('.md')) { - continue; - } + validateMirrorPage(join(directory, entry.name), `docs/${relative}.md`); + } + }; + walk(mirrorRoot, ''); +} - const relativePath = `docs/${entry.name}/${page}`; - const markdown = readFileSync(join(mirrorRoot, entry.name, page), 'utf8'); - const frontmatterMatch = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$/.exec(markdown); - if (!frontmatterMatch?.[1] || frontmatterMatch[2] === undefined) { - fail(`Generated Markdown has invalid front matter: ${relativePath}.`); - continue; - } +/** Front-matter and body invariants applied to every generated mirror page. */ +function validateMirrorPage(file: string, relativePath: string): void { + const markdown = readFileSync(file, 'utf8'); + const frontmatterMatch = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$/.exec(markdown); + if (!frontmatterMatch?.[1] || frontmatterMatch[2] === undefined) { + fail(`Generated Markdown has invalid front matter: ${relativePath}.`); + return; + } - let frontmatter: Record; - try { - frontmatter = parse(frontmatterMatch[1]) as Record; - } catch { - fail(`Generated Markdown front matter is not valid YAML: ${relativePath}.`); - continue; - } + let frontmatter: Record; + try { + frontmatter = parse(frontmatterMatch[1]) as Record; + } catch { + fail(`Generated Markdown front matter is not valid YAML: ${relativePath}.`); + return; + } - const body = frontmatterMatch[2]; - const description = String(frontmatter.description ?? '').trim(); - if (!description || description === '---' || !/[A-Za-z0-9]/.test(description)) { - fail(`Generated Markdown has an unreadable description: ${relativePath}.`); - } - if (!extractDescription(body)) { - fail(`Generated Markdown has no readable prose: ${relativePath}.`); - } - const { titleCount, unclosedFence } = inspectMarkdownStructure(body); - if (titleCount !== 1) { - fail(`Generated Markdown must contain exactly one H1: ${relativePath} (${titleCount}).`); - } - if (unclosedFence) { - fail(`Generated Markdown has an unclosed fenced block: ${relativePath}.`); - } - if (body.includes(' | undefi } } +/** + * True when two channels publish the same pages from the same source text. + * This is the "the prerelease has been promoted" signal: the tag now carries + * the same docs as the default branch, so the second channel adds nothing. + */ +function publishesSamePages(a: DocsManifestChannel, b: DocsManifestChannel): boolean { + if (a.pages.length !== b.pages.length) { + return false; + } + const bySlug = new Map(b.pages.map((page) => [page.slug, page])); + return a.pages.every((page) => { + const other = bySlug.get(page.slug); + if (!other) { + return false; + } + return page.hash ? other.hash === page.hash : other.sourcePath === page.sourcePath; + }); +} + /** Documentation must resolve for every documented, non-archived project. */ function checkDocs( project: ResolvedProject, docsManifest: DocsManifest | null, + data: ReleaseCacheData | null, ): Set | undefined { if (!project.docs) { return undefined; @@ -138,7 +159,10 @@ function checkDocs( }); } const docsProject = docsManifest?.projects.find((entry) => entry.projectId === project.id); - const pages = new Set((docsProject?.pages ?? []).map((page) => page.slug)); + const live = defaultDocsChannel(docsProject); + // Use-case deep links point at the canonical `/docs///` URLs, so + // they resolve against the default channel only. + const pages = new Set(docsChannelPages(docsProject).map((page) => page.slug)); if (project.status === 'archived') { return pages; } @@ -148,11 +172,105 @@ function checkDocs( field: 'docs', observed: 'no aggregated pages', expected: 'at least one page under /docs//', - remediation: `check that ${project.docs.source} resolves on branch main, run \`just live-data-sync\`, and review the sync output.`, + remediation: `check that ${project.docs.source} resolves at the channel refs, run \`just live-data-sync\`, and review the sync output.`, + }); + return pages; + } + + const declared = project.docs.source === 'github-path' ? project.docs.channels : undefined; + if (declared) { + for (const id of DOC_CHANNELS) { + if (declared[id] === undefined) { + continue; + } + const channel = docsProject.channels.find((candidate) => candidate.id === id); + // A prerelease channel is published only while its line is ahead of the + // newest stable release (ADR 0009). Absence at promotion time is expected, + // not drift, so it is an observation rather than a blocker. + const gate = + data === null + ? null + : id === 'prerelease' + ? prereleaseLinePromoted(project, data) + : { publish: true }; + if (!channel) { + if (gate && !gate.publish) { + warn({ + project: project.id, + field: `docs.channels.${id}`, + observed: `not published (${gate.reason})`, + expected: + 'a prerelease channel only while a prerelease outranks the latest stable release', + remediation: + 'nothing to do: the channel is declared and returns automatically with the next prerelease release.', + }); + continue; + } + fail({ + project: project.id, + field: `docs.channels.${id}`, + observed: 'not present in the aggregated documentation', + expected: `a "${id}" channel in src/.cache/docs/index.json`, + remediation: + 'run `just data-sync` and review the sync output: the pinned ref may not contain the docs path, or the channel duplicated another ref and was dropped.', + }); + continue; + } + if (channel.pages.length === 0) { + fail({ + project: project.id, + field: `docs.channels.${id}`, + observed: '0 pages', + expected: 'at least one page', + remediation: `fix the ${id} channel source/exclude configuration and re-run \`just data-sync\`.`, + }); + continue; + } + if (gate && !gate.publish) { + warn({ + project: project.id, + field: `docs.channels.${id}`, + observed: `published while ${gate.reason}`, + expected: + 'a prerelease channel only while a prerelease outranks the latest stable release', + remediation: + 'run `just data-sync` so the mirror is rebuilt from the current release data.', + }); + } + if (!channel.default && live && publishesSamePages(channel, live)) { + warn({ + project: project.id, + field: `docs.channels.${id}`, + observed: `publishes the same ${channel.pages.length} page(s) as the "${live.id}" channel`, + expected: 'a second channel only while the prerelease differs from the live release', + remediation: + 'nothing to do: the channel is withheld automatically once the release data shows the prerelease has shipped.', + }); + } + } + const undeclared = docsProject.channels.filter((channel) => declared[channel.id] === undefined); + if (undeclared.length > 0) { + warn({ + project: project.id, + field: 'docs.channels', + observed: `the docs cache has a "${undeclared[0]!.id}" channel that the manifest does not declare`, + expected: 'a docs cache written from the current manifest', + remediation: 'run `just data-sync` so the mirror, sidebar and manifest agree.', + }); + } + } + + if (!live) { + fail({ + project: project.id, + field: 'docs', + observed: 'no default channel', + expected: 'one channel that owns /docs//', + remediation: 'run `just data-sync`; every documented project must contribute a live channel.', }); return pages; } - if (docsProject.pages.length === 0) { + if (live.pages.length === 0) { fail({ project: project.id, field: 'docs', @@ -609,7 +727,7 @@ export function checkProjects(): { errors: Finding[]; warnings: Finding[] } { } for (const project of projects) { - const docsPages = checkDocs(project, docsManifest); + const docsPages = checkDocs(project, docsManifest, data); checkUseCases(project, docsPages); checkExperimental(project); if (data) { diff --git a/src/scripts/data-sync.ts b/src/scripts/data-sync.ts index baa1e33..da30ebb 100644 --- a/src/scripts/data-sync.ts +++ b/src/scripts/data-sync.ts @@ -5,7 +5,7 @@ import { readReleaseCache, readReleaseFixture, writeReleaseCache } from '../src/ import { fetchReleaseData } from './fetch-releases'; import { processBranding } from './process-branding'; import { syncAssets } from './sync-assets'; -import { hasDocsMirror, syncDocs } from './sync-docs'; +import { hasCurrentDocsMirror, hasDocsMirror, syncDocs } from './sync-docs'; const RELEASES_CACHE_FILE = resolve('.cache/releases/releases.json'); @@ -67,18 +67,25 @@ async function syncReleases(mode: Mode): Promise { } async function syncDocsMirror(mode: Mode): Promise { + const current = hasCurrentDocsMirror(); if (mode === 'cache' || mode === 'fixture') { - if (hasDocsMirror()) { + if (current) { console.log('Docs: using existing mirror.'); return; } + if (hasDocsMirror()) { + throw new Error( + 'Docs: the mirror at src/content/docs was written by an older docs manifest schema. ' + + 'Run `just data-sync` (live) once to regenerate it.', + ); + } if (mode === 'cache') { throw new Error( 'DATA_MODE=cache but no docs mirror exists at src/content/docs/docs. Run `just data-sync` with live mode first.', ); } } - if (hasDocsMirror() && mode === 'auto') { + if (current && mode === 'auto') { console.log('Docs: using existing mirror.'); return; } diff --git a/src/scripts/sync-docs.ts b/src/scripts/sync-docs.ts index 48f4611..5f8a43c 100644 --- a/src/scripts/sync-docs.ts +++ b/src/scripts/sync-docs.ts @@ -1,7 +1,12 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { aggregateDocs, writeDocs, writeDocsManifest } from '../src/lib/docs/aggregate'; +import { + aggregateDocs, + readDocsManifest, + writeDocs, + writeDocsManifest, +} from '../src/lib/docs/aggregate'; import { loadProjects } from '../src/lib/manifest/load'; /** Fetch and write the aggregated documentation mirror. */ @@ -17,7 +22,11 @@ export async function syncDocs(): Promise<{ projects: number; pages: number }> { 'Check GitHub API access (rate limits) and retry: run `gh auth login` or set GITHUB_TOKEN.', ); } - const pages = manifest.projects.reduce((total, entry) => total + entry.pages.length, 0); + const pages = manifest.projects.reduce( + (total, entry) => + total + entry.channels.reduce((count, channel) => count + channel.pages.length, 0), + 0, + ); console.log(`Docs synced: ${manifest.projects.length} projects, ${pages} pages.`); return { projects: manifest.projects.length, pages }; } @@ -27,6 +36,16 @@ export function hasDocsMirror(): boolean { return existsSync(resolve('src/content/docs')); } +/** + * True when the mirror and its manifest agree with the current schema. A + * manifest written by an older schema reads as absent (`readDocsManifest` + * returns null), which is what forces a re-sync after a schema bump instead of + * building a sidebar for page paths the mirror no longer has. + */ +export function hasCurrentDocsMirror(): boolean { + return hasDocsMirror() && readDocsManifest() !== null; +} + if (import.meta.main) { await syncDocs(); } diff --git a/src/src/components/starlight/EditLink.astro b/src/src/components/starlight/EditLink.astro index 66246ca..bf1cecf 100644 --- a/src/src/components/starlight/EditLink.astro +++ b/src/src/components/starlight/EditLink.astro @@ -3,13 +3,19 @@ import { Icon } from '@astrojs/starlight/components'; const { entry } = Astro.locals.starlightRoute; const editUrl = entry?.data?.editUrl; +// A channel pinned to a release tag is immutable, so its link points at the +// file at that tag and offers to view rather than edit it (ADR 0009). +const refKind = entry?.data?.docsRefKind; +const editable = refKind === undefined || refKind === 'branch'; +const ref = entry?.data?.docsRef; +const label = editable ? Astro.locals.t('page.editLink') : `View source${ref ? ` (${ref})` : ''}`; --- { editUrl && ( - - {Astro.locals.t('page.editLink')} + + {label} ) } \ No newline at end of file diff --git a/src/src/components/starlight/PageTitle.astro b/src/src/components/starlight/PageTitle.astro index 9995311..05dd00b 100644 --- a/src/src/components/starlight/PageTitle.astro +++ b/src/src/components/starlight/PageTitle.astro @@ -2,6 +2,8 @@ import GitHubIcon from '~/components/GitHubIcon.astro'; import StarOnGitHub from '~/components/StarOnGitHub.astro'; import StatusBadge from '~/components/StatusBadge.astro'; +import { readDocsManifest } from '~/lib/docs/aggregate'; +import { channelBasePath, channelDisplayLabel } from '~/lib/docs/channels'; import { isStale, STALE_AFTER_DAYS } from '~/lib/docs/staleness'; import { LLMS_LINK_ATTRS } from '~/lib/llms'; import { getReleaseIndex } from '~/lib/releases/runtime'; @@ -12,11 +14,31 @@ import { withBase } from '~/lib/urls'; const { entry } = Astro.locals.starlightRoute; const data = entry?.data; const lastReviewed = typeof data?.lastReviewed === 'string' ? data.lastReviewed : undefined; -const stale = lastReviewed ? isStale(lastReviewed) : false; const tags = Array.isArray(data?.tags) ? data.tags : []; const experimental = data?.experimental === true; const status = data?.status ? displayStatus({ status: data.status, experimental }) : undefined; +// Documentation channels (ADR 0009): a project may publish a live channel and a +// prerelease one. The switcher is only rendered when the project really has two +// channels, so single-channel projects look exactly as before. +const docsManifest = readDocsManifest(); +const sourceProjectId = typeof data?.sourceProject === 'string' ? data.sourceProject : undefined; +const docsProject = docsManifest?.projects.find( + (project) => project.projectId === sourceProjectId, +); +const channels = docsProject?.channels ?? []; +const activeChannelId = data?.docsChannel; +const activeChannel = channels.find((channel) => channel.id === activeChannelId); +const liveChannel = channels.find((channel) => channel.default); +const showChannelSwitcher = channels.length > 1 && activeChannel !== undefined; +const channelHref = (channelId: 'live' | 'prerelease', isDefault: boolean): string => + withBase(channelBasePath(sourceProjectId ?? '', channelId, isDefault)); + +// A tag-pinned channel does not move, so its review date is the release date: +// flagging it as stale would be noise rather than drift. +const pinnedChannel = activeChannel?.refKind === 'tag'; +const stale = lastReviewed && !pinnedChannel ? isStale(lastReviewed) : false; + // Project overview pages surface the per-project `llms.txt` bundle emitted by // the discovery integration at /projects//llms.txt. Other aggregated pages // keep the heading lean and only link the global files from the site footer. @@ -35,6 +57,39 @@ const llmsHref =

{data?.title}

+{ + showChannelSwitcher && ( + + ) +} + +{ + activeChannel && !activeChannel.default && liveChannel && ( +

+ Prerelease. These pages describe {activeChannel.label} and can differ from + the published release. Read the + + {channelDisplayLabel(liveChannel)} + + documentation instead. +

+ ) +} + { data && (
@@ -125,6 +180,58 @@ const llmsHref = }