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
20 changes: 18 additions & 2 deletions docs/decisions/0009-documentation-channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,24 @@ history has to survive between builds, and an existing page is never overwritten
`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.
- Channels are labelled with the **version they document**, not the ref they read.
A tag channel names its own version, and a `prerelease` channel that follows
`main` is labelled with the newest published prerelease
(`Prerelease 3.0.0-prerelease.2`): a reader chooses between versions, and a
branch name says nothing about which one the page describes. A `live` channel
that fell back to a branch has no stable release to name and keeps the branch,
as does a commit SHA. The resolved `ref` stays on the record, so the branch is
still visible and the edit links still target it.
- The page switcher is a native `<details>` drop-down: the summary names the
channel being read, the menu lists every version, the current one carries
`aria-current`, and the ref is each option's tooltip. It needs no JavaScript,
so the versions stay reachable, and every option is a real link. It exists only
while two channels are published: promotion withholds the prerelease channel,
so the switcher disappears with it and nothing in the UI advertises a version
that is no longer published (its retired URLs redirect instead).
- The sidebar shows one Starlight group per channel with the same labels
(`Live 2.4.2`, `Prerelease 3.0.0-prerelease.2`); 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.
Expand Down
18 changes: 17 additions & 1 deletion src/scripts/check-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,23 @@ function checkDocs(
? prereleaseLinePromoted(project, data)
: { publish: true };
if (!channel) {
if (gate && !gate.publish) {
if (gate === null) {
// No release data, so nothing can say whether this channel should exist.
// Reporting it against `docs.channels` points the reviewer at the manifest
// and the project's documentation, which are the two things that are
// usually fine: the missing input is the release cache.
warn({
project: project.id,
field: `docs.channels.${id}`,
observed:
'not present in the aggregated documentation, and no release data to explain it',
expected: 'a docs mirror built from the release data this check reads',
remediation:
'run `just data-sync` so the mirror and the catalogue are built from the same release data.',
});
continue;
}
if (!gate.publish) {
warn({
project: project.id,
field: `docs.channels.${id}`,
Expand Down
21 changes: 16 additions & 5 deletions src/scripts/sync-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { resolve } from 'node:path';

import {
aggregateDocs,
isMirrorManifestCurrent,
readDocsManifest,
writeDocs,
writeDocsManifest,
} from '../src/lib/docs/aggregate';
import { loadProjects } from '../src/lib/manifest/load';
import { getReleaseIndex } from '../src/lib/releases/runtime';

/** Fetch and write the aggregated documentation mirror. */
export async function syncDocs(): Promise<{ projects: number; pages: number }> {
Expand Down Expand Up @@ -37,13 +39,22 @@ export function hasDocsMirror(): boolean {
}

/**
* 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.
* True when the mirror can be reused: it exists, its manifest was written by the
* current schema, and it was aggregated from the release data that is in hand.
*
* A manifest written by an older schema reads as absent (`readDocsManifest`
* returns null), which forces a re-sync instead of building a sidebar for page
* paths the mirror no longer has. The vintage check covers the other half: the
* schema can be current while the release data is not, and channels are decided
* from release data — so a mirror that predates the latest fetch documents a
* release line that has since moved on. Reusing it publishes a stale channel
* decision, and `check:projects` then reports the missing channel as drift.
*/
export function hasCurrentDocsMirror(): boolean {
return hasDocsMirror() && readDocsManifest() !== null;
if (!hasDocsMirror()) {
return false;
}
return isMirrorManifestCurrent(readDocsManifest(), getReleaseIndex().data.retrievedAt);
}

if (import.meta.main) {
Expand Down
118 changes: 94 additions & 24 deletions src/src/components/starlight/PageTitle.astro
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,39 @@ const llmsHref =
<h1 id="_top">{data?.title}</h1>

{
showChannelSwitcher && (
showChannelSwitcher && activeChannel && (
<nav class="doc-channels" aria-label="Documentation version">
<span class="doc-channels-label">Docs for</span>
{channels.map((channel) => (
<a
href={channelHref(channel.id, channel.default)}
class:list={[
'doc-channel',
{ 'doc-channel-current': channel.id === activeChannelId },
]}
aria-current={channel.id === activeChannelId ? 'page' : undefined}
>
{channelDisplayLabel(channel)}
</a>
))}
<details class="doc-channel-menu">
<summary>
{channelDisplayLabel(activeChannel)}
<span class="doc-channel-caret" aria-hidden="true">
▾
</span>
</summary>
<ul class="doc-channel-options">
{channels.map((channel) => (
<li>
<a
href={channelHref(channel.id, channel.default)}
class:list={[
'doc-channel-option',
{ 'doc-channel-option-current': channel.id === activeChannelId },
]}
aria-current={channel.id === activeChannelId ? 'page' : undefined}
title={`Documentation from ${channel.ref}`}
>
<span>{channelDisplayLabel(channel)}</span>
{channel.id === activeChannelId && (
<span class="doc-channel-check" aria-hidden="true">
✓
</span>
)}
</a>
</li>
))}
</ul>
</details>
</nav>
)
}
Expand Down Expand Up @@ -181,9 +199,11 @@ const llmsHref =

<style>
/* Channel switcher (ADR 0009). Rendered only when a project publishes both a
* live and a prerelease channel; the current channel is marked with
* aria-current as well as colour, and the label reads as prose rather than a
* bare toggle. */
* live and a prerelease channel. It is a native <details> disclosure so the
* versions stay reachable without JavaScript: the summary names the channel
* the reader is on, the menu lists every version, and each option is a real
* link (middle-click and open-in-new-tab keep working). The current option is
* marked with aria-current as well as colour. */
.doc-channels {
display: flex;
flex-wrap: wrap;
Expand All @@ -198,28 +218,78 @@ const llmsHref =
margin-right: 0.125rem;
}

.doc-channel {
.doc-channel-menu {
position: relative;
}

.doc-channel-menu > summary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
list-style: none;
border-radius: 9999px;
border: 1px solid var(--sl-color-hairline);
background-color: var(--sl-color-gray-6);
border: 1px solid var(--color-brand);
background-color: color-mix(in oklab, var(--color-brand) 12%, transparent);
padding: 0.125rem 0.625rem;
color: var(--color-brand);
font-weight: 600;
white-space: nowrap;
}

.doc-channel-menu > summary::-webkit-details-marker {
display: none;
}

.doc-channel-menu > summary:hover,
.doc-channel-menu[open] > summary {
background-color: color-mix(in oklab, var(--color-brand) 22%, transparent);
}

.doc-channel-caret {
font-size: 0.625rem;
line-height: 1;
}

.doc-channel-options {
position: absolute;
z-index: 20;
margin: 0.375rem 0 0;
padding: 0.25rem;
min-width: 100%;
list-style: none;
border-radius: 0.5rem;
border: 1px solid var(--sl-color-hairline);
background-color: var(--sl-color-bg-nav, var(--sl-color-black));
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.25);
}

.doc-channel-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-radius: 0.375rem;
padding: 0.1875rem 0.5rem;
color: var(--sl-color-gray-2);
text-decoration: none;
white-space: nowrap;
}

.doc-channel:hover {
border-color: var(--color-brand);
.doc-channel-option:hover {
background-color: var(--sl-color-gray-6);
color: var(--color-brand);
}

.doc-channel-current {
border-color: var(--color-brand);
background-color: color-mix(in oklab, var(--color-brand) 12%, transparent);
.doc-channel-option-current {
color: var(--color-brand);
font-weight: 600;
}

.doc-channel-check {
font-size: 0.6875rem;
}

/* Stated in prose as well as in the switcher, so a reader who lands on a
* prerelease page from search knows which version they are reading. */
.doc-prerelease {
Expand Down
38 changes: 37 additions & 1 deletion src/src/lib/docs/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,17 @@ export const DOCS_CACHE_DIR = resolve('.cache/docs');
* (`readDocsManifest` returns null), so a schema bump forces a re-sync instead
* of building a sidebar for page paths that no longer exist.
*/
export const DOCS_MANIFEST_SCHEMA = 2;
/**
* Version 3 records the release data the mirror was built from. Channels are
* decided from release data (`resolveDocChannels`), so a mirror built before a
* fetch describes a different release line than the cache now holds — the
* `2.1.0`/`2.0.0-prerelease.28` vintage in an August build, say. Comparing
* `releaseRetrievedAt` with the current cache is what makes such a mirror read
* as stale instead of as "current" with a missing channel. A schema bump also
* forces a re-sync, which is why the vintage is part of the schema rather than
* an optional extra: a version-2 manifest has no vintage to compare.
*/
export const DOCS_MANIFEST_SCHEMA = 3;

export interface AggregatedPage {
slug: string;
Expand Down Expand Up @@ -88,9 +98,31 @@ export interface DocsManifestProject {
export interface DocsManifest {
schema: typeof DOCS_MANIFEST_SCHEMA;
syncedAt: string;
/**
* The `retrievedAt` of the release data this mirror was aggregated from.
* `hasCurrentDocsMirror` compares it with the cache, so a mirror built from an
* older release snapshot is rebuilt rather than reused.
*/
releaseRetrievedAt: string;
projects: DocsManifestProject[];
}

/**
* Whether a written manifest may be reused against the release data now in hand.
*
* Two things must hold, and they fail differently: a manifest from an older
* schema has no usable shape at all, and a manifest from an older release
* vintage describes channels that were decided before the line moved on. The
* second is the subtle one — the schema is current, every page is present, and
* the only symptom is a channel that should exist and does not.
*/
export function isMirrorManifestCurrent(
manifest: DocsManifest | null,
releaseRetrievedAt: string,
): boolean {
return manifest !== null && manifest.releaseRetrievedAt === releaseRetrievedAt;
}

/** The channel that owns the canonical `/docs/<id>/` URLs. */
export function defaultDocsChannel(
project: DocsManifestProject | undefined,
Expand Down Expand Up @@ -758,6 +790,10 @@ export function writeDocs(
const manifest: DocsManifest = {
schema: DOCS_MANIFEST_SCHEMA,
syncedAt: new Date().toISOString(),
// Recorded, not inferred: the channels below were resolved from this snapshot
// of release data, so reusing the mirror against a different snapshot would
// publish a channel decision nobody made.
releaseRetrievedAt: getReleaseIndex().data.retrievedAt,
projects: [],
};

Expand Down
47 changes: 39 additions & 8 deletions src/src/lib/docs/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,30 @@ function publishedVersions(project: ResolvedProject, data: ReleaseCacheData): st
return versions;
}

/**
* The version a branch-pinned channel documents, for display.
*
* A tag channel names its own version. A branch channel names the line it is
* preparing instead, which says nothing to a reader choosing between two
* versions: `main` on a `prerelease` channel is the line that ships the newest
* published prerelease, so that version is the honest label. The resolved `ref`
* is kept, so the UI can still show the branch (the page switcher uses it as a
* tooltip) and the edit links still target it.
*
* A `live` channel that fell back to a branch has no stable release to name and
* keeps the branch name, and a commit SHA is frozen, so neither is relabelled.
*/
function documentedVersion(
project: ResolvedProject,
data: ReleaseCacheData,
channel: { id: DocChannelId; refKind: DocRefKind },
): string | null {
if (channel.id !== 'prerelease' || channel.refKind === 'sha') {
return null;
}
return projectReleaseLine(project, data).prerelease;
}

export interface PrereleaseChannelGate {
publish: boolean;
/** Why the channel is withheld, phrased for the sync log and the catalogue guard. */
Expand Down Expand Up @@ -333,14 +357,21 @@ export function resolveDocChannels(
};
}
return {
channels: published.map((channel) => ({
id: channel.id,
ref: channel.ref,
refKind: channel.refKind,
version: channel.version,
label: channel.label,
default: channel === defaultChannel,
})),
channels: published.map((channel) => {
// A branch-pinned channel still documents a version: `main` on a
// `prerelease` channel is the line that ships the newest published
// prerelease. Naming that version — instead of the branch — is what lets a
// reader tell the two channels apart in the page switcher (ADR 0009).
const version = channel.version ?? documentedVersion(project, data, channel);
return {
id: channel.id,
ref: channel.ref,
refKind: channel.refKind,
version,
label: version ?? channel.label,
default: channel === defaultChannel,
};
}),
warnings,
};
}
Expand Down
Loading
Loading