Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions .agents/skills/docs-aggregation-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path>/**/*.md` | Each file fetched from `raw.githubusercontent.com` on branch `main` |
| `wiki` | `git clone --depth 1 https://github.com/<repo>.wiki.git` | `_Sidebar.md` and `_Footer.md` are never rendered |
| `readme` | Raw `README.md` | Exactly one page (`index`) |
| `github-path` | Repository tree (`git/trees/<ref>?recursive=1`) filtered to `<path>/**/*.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/<repo>.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/<id>/<page>.md` and served at `/docs/<id>/<page>/`.
- Any other channel is written to
`src/src/content/docs/docs/<id>/<channel>/<page>.md` and served at
`/docs/<id>/<channel>/<page>/`.
- `docs/<id>/<channel>` 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 <id>: 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)

Expand Down
44 changes: 41 additions & 3 deletions .agents/skills/project-manifest-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<path>/**/*.md` on branch `main` |
| `wiki` | `rootPage`, `readmeAsIndex`, `order`, `exclude` | Shallow-clones `<repo>.wiki.git`; `_Sidebar.md` orders pages, `_Footer.md` is skipped |
| `readme` | none | Publishes `README.md` alone as `/docs/<id>/` |
| `github-path` | `path` (required), plus `rootPage`, `readmeAsIndex`, `order`, `exclude`, `channels` | Walks `<path>/**/*.md` at each channel's ref (default branch when no `channels`) |
| `wiki` | `rootPage`, `readmeAsIndex`, `order`, `exclude` | Shallow-clones `<repo>.wiki.git`; `_Sidebar.md` orders pages, `_Footer.md` is skipped. Single channel only |
| `readme` | none | Publishes `README.md` alone as `/docs/<id>/`. 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
Expand All @@ -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/<id>/…`; `prerelease` is
served at `/docs/<id>/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).
Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/`, the
other is served at `/docs/<id>/<channel>/`, 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
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project>/`
URLs; the `prerelease` channel is served at `/docs/<project>/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.
Expand Down
165 changes: 165 additions & 0 deletions docs/decisions/0009-documentation-channels.md
Original file line number Diff line number Diff line change
@@ -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/<id>/…` (canonical) |
| `prerelease` | The line that has not shipped yet | `/docs/<id>/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/<id>/…` 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/<id>/prerelease/` and each
`/docs/<id>/prerelease/<page>/` — 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/<id>/prerelease/<page>.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/<id>/2.4/`, `/docs/<id>/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/<id>/<page>/` 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.
5 changes: 5 additions & 0 deletions src/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading