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
18 changes: 16 additions & 2 deletions .agents/skills/docs-aggregation-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,11 @@ The wikis of several product repositories are mirrored into `docs/wiki`, so the
- Page order comes from, in priority: `docs.order` (explicit slug list) → a `_Sidebar.md` link order
(both for `wiki` and for a mirrored wiki under `github-path`) → `1000`.
- The landing page is always `order: -1`, so it heads the sidebar as "Overview".
- Page slugs are slugified file names (`Getting-Started.md` → `getting-started`) and the URLs are
`/docs/<id>/<slug>/`. A `docsPage` in a use case must use that slug form.
- A page's slug is its path from the docs root (ADR 0011): a flat corpus keeps the single-segment
slug (`Getting-Started.md` → `getting-started`, served at `/docs/<id>/getting-started/`), and a
topic folder stays a path segment (`schemas/Custom-Rules.md` →
`/docs/<id>/schemas/custom-rules/`). A `docsPage` in a use case must use a slug that still resolves
in the **default** channel, which is why it stays a single segment.

## Injected front matter (per page)

Expand All @@ -128,6 +131,13 @@ H1s are demoted to keep one title per document.

- Each documented project becomes a `starlight-sidebar-topics` topic whose items come from
`src/.cache/docs/index.json` (so the mirror and the manifest must come from the same sync).
- The sidebar mirrors the published tree (ADR 0011): every folder in a slug becomes a nested
`starlight` group labelled with the folder's name spelled out (`schemas` → `Schemas`), placed where
its first page appears and holding all of that folder's pages in channel order. Nesting is
recursive, a folder's `index.md` links the folder's own address, and a flat corpus renders exactly
the flat list it always did (`buildChannelItems` in `src/lib/docs/sidebar.ts`).
- Each aggregated page also names the folders above it, above the title, in the same spelling as the
sidebar (`PageTitle.astro` + `docSectionTrail`).
- Archived projects are excluded from the docs portal, the sidebar topics, and per-project LLM
bundles, even if they declare `docs`.
- Every documented project gets `/projects/<id>/llms.txt` and `/projects/<id>/llms-full.txt`, linked
Expand All @@ -142,3 +152,7 @@ just data-sync # expect "docs <id>: <n> pages" per project
node -e "console.log(require('./src/.cache/docs/index.json').projects)"
just build && just check-generated # asserts the mirror matches the manifest
```

A reorganised tree is verified in the built output: the old flat slug must be a meta-refresh page
pointing at the new address, on every channel that publishes the tree (`/docs/<id>/<old>/` and
`/docs/<id>/prerelease/<old>/`), and the sidebar of a nested channel must contain a `group-label`.
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,13 @@ prints advisory observations (which never fail the build) when a project's decla
- A page's slug is its path from the docs root (ADR 0011): a topic folder stays a path segment
(`schemas/Custom-Rules.md` is published at `/docs/<id>/schemas/custom-rules/`), a flat corpus
keeps the single-segment slug it always had, and every relative link resolves against the page
that contains it. A project whose docs are reorganised records the old→new slug in
`docs.redirects`, which the build turns into redirects; a redirect never shadows a real page.
that contains it. The display mirrors that tree: the docs sidebar nests each folder as a group
(labelled with the folder's name spelled out) and each page names its section above the title,
so a flat corpus renders exactly as before. A project whose docs are reorganised records the
old→new slug in `docs.redirects`, which the build turns into redirects on **every** channel that
publishes the tree — the default channel at `/docs/<id>/` and the prerelease one at
`/docs/<id>/prerelease/`, each pair written only once its destination exists; a redirect never
shadows a real page.
- Agent Skills a project publishes are declared in `skills:` (ADR 0010) at
`skills/<name>/SKILL.md`; the sync mirrors the whole directory to
`/skills/<id>/<name>/` and each project page renders the same install block
Expand Down
39 changes: 37 additions & 2 deletions docs/decisions/0011-documentation-folders.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,35 @@ the newer tree, the new address is unpublished too, so forwarding the reader wou
be forwarding into a 404 — the post-build link crawl fails the build on exactly
that, which is how this was found. Both halves activate per page, at promotion.

### 4. `docsPage` stays a single segment
A pair is emitted for every base the project publishes: the default channel at
`/docs/<id>/` and, when `live` and `prerelease` are both declared, the prerelease
channel at `/docs/<id>/prerelease/`. The reorganised tree is usually published on
the prerelease channel *first* — that is what a default-branch docs change is —
and its readers follow URLs that move at the same moment the default channel's
do later. The same two guards decide which pairs are real, so a withheld channel
still writes nothing here; its URLs belong to the retired-channel redirects.

### 4. The sidebar and the page show the tree the URL describes

Publishing a hierarchy in the URL and then flattening it in the sidebar would
leave the reader with the one thing folders were meant to fix: grouping that the
address disagrees with. So the display follows the slug:

- `buildChannelItems` nests a channel's pages into Starlight sidebar groups, one
per folder, labelled with the folder's segment spelled out (`schemas` →
`Schemas`, `json-schema` → `JSON Schema`). A group appears where its first page
appears and absorbs its later pages, so a folder is listed once and the sync's
reading order (`docs.order` / `_Sidebar.md`) survives.
- Nesting is recursive, because a slug is a path: `guides/getting-started/install`
is a group inside a group. A folder's index file (`schemas/index.md`) is
published at the folder's own address, so its sidebar entry links the folder.
- A flat corpus has no folder in any slug, so it produces no group and no code
path changes for the projects that never nested.
- A page names its section above the title (`PageTitle.astro`), using the same
spelling as the sidebar, so a reader arriving from search knows which part of
the documentation they are in.

### 5. `docsPage` stays a single segment

The catalogue's deep-link slug keeps its `^[a-z0-9-]+$` shape and resolves against
the default channel. A deep link must therefore keep working across two trees that
Expand All @@ -94,7 +122,14 @@ move the failure to the moment a channel's tree diverges.
- Documentation written for a flat corpus is unaffected in both slug and link
terms, so this landed without touching another project's catalogue.
- ZodSharp's own move is the first user of `docs.redirects`: 30 entries that stay
inert until the release that carries the folders is promoted to `live`.
inert on the default channel until the release that carries the folders is
promoted to `live`, and apply immediately to the prerelease channel, which reads
the reorganised tree today.
- `buildChannelItems` returns a tree instead of a list. That is the only shape
change the display took: `SidebarDocItem[]` replaced `SidebarSlugEntry[]`, and
every other use of the docs cache (`llms` bundles, discovery resources, the docs
portal) still reads the flat page list, which remains the source of truth for
what a channel publishes.

## Alternatives considered

Expand Down
31 changes: 31 additions & 0 deletions src/src/components/starlight/PageTitle.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { docSectionTrail } from '~/lib/docs/sidebar';
import { isStale, STALE_AFTER_DAYS } from '~/lib/docs/staleness';
import { projectChannelLlmsFullPath, projectChannelLlmsPath } from '~/lib/discovery/urls';
import { LLMS_LINK_ATTRS } from '~/lib/llms';
Expand Down Expand Up @@ -35,6 +36,14 @@ const showChannelSwitcher = channels.length > 1 && activeChannel !== undefined;
const channelHref = (channelId: 'live' | 'prerelease', isDefault: boolean): string =>
withBase(channelBasePath(sourceProjectId ?? '', channelId, isDefault));

// A page's folder is part of its URL (ADR 0011), so a reader who arrives from
// search on `schemas/custom-rules` is told which section of the documentation
// they are in, not only which project. The trail is the same spelling the
// sidebar uses for its folder groups, and it is empty for a flat corpus — a
// page at the docs root has no section above it.
const activePage = activeChannel?.pages.find((page) => page.sourcePath === data?.sourcePath);
const sectionTrail = activePage ? docSectionTrail(activePage.slug) : [];

// 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';
Expand Down Expand Up @@ -86,6 +95,15 @@ const starCount =
: (getReleaseIndex().data.repos[sourceRepo]?.stargazersCount ?? null);
---

{
sectionTrail.length > 0 && (
<p class="doc-sections">
<span class="sr-only">Documentation section: </span>
{sectionTrail.join(' / ')}
</p>
)
}

<h1 id="_top">{data?.title}</h1>

{
Expand Down Expand Up @@ -224,6 +242,19 @@ const starCount =
}

<style>
/* The section trail names the folders above the page (ADR 0011). It sits
* directly above the title, in the same tracked-uppercase treatment as the
* sidebar's folder groups, so the two read as the same hierarchy. Hidden for
* pages at the docs root, which have no section. */
.doc-sections {
margin: 0 0 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--sl-color-gray-2);
}

/* Channel switcher (ADR 0009). Rendered only when a project publishes both a
* live and a prerelease channel. It is a native <details> disclosure so the
* versions stay reachable without JavaScript: the summary names the channel
Expand Down
30 changes: 24 additions & 6 deletions src/src/lib/docs/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ export function retiredChannelRedirects(
* catalogue. `writeStaticRedirect` never overwrites a file, which is what makes
* the declaration timing-safe - while the `live` channel still reads an older tag,
* the old slug is a real page and the redirect is simply not written.
*
* Every published channel owns URLs of its own, so a move is forwarded from each
* base the project publishes: the default channel at `/docs/<id>/` and, when the
* project declares both channels, the prerelease one at `/docs/<id>/prerelease/`.
* A tree can be reorganised on the default branch long before the release that
* carries it is promoted, and the readers on the prerelease channel are following
* URLs that change at the same moment. Whether a pair is real is decided by the
* write guards exactly as for the default channel, so a withheld channel writes
* nothing here (its URLs are retired-channel redirects instead).
*/
export function docsPageRedirects(projects: readonly ResolvedProject[]): DocChannelRedirect[] {
const redirects: DocChannelRedirect[] = [];
Expand All @@ -113,13 +122,22 @@ export function docsPageRedirects(projects: readonly ResolvedProject[]): DocChan
continue;
}
const moved = project.docs.redirects ?? {};
const bases = [`/docs/${project.id}`];
// `prerelease` is only a second channel when `live` is declared with it;
// on its own it would be the default channel and own the canonical URLs.
const declared = project.docs.channels;
if (declared?.live !== undefined && declared.prerelease !== undefined) {
bases.push(`/docs/${project.id}/prerelease`);
}
for (const [from, to] of Object.entries(moved)) {
redirects.push({
from: `/docs/${project.id}/${from}/`,
to: `/docs/${project.id}/${to}/`,
title: 'This documentation page moved…',
message: 'The page now lives at its new address.',
});
for (const base of bases) {
redirects.push({
from: `${base}/${from}/`,
to: `${base}/${to}/`,
title: 'This documentation page moved…',
message: 'The page now lives at its new address.',
});
}
}
}
return redirects;
Expand Down
146 changes: 135 additions & 11 deletions src/src/lib/docs/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,29 @@ import type { DocsManifest, DocsManifestChannel } from './aggregate';

import { channelBasePath } from './channels';

/** A single documentation page, linked by its content-collection slug. */
export interface SidebarSlugEntry {
label?: string;
slug: string;
}

/**
* A folder of the documentation tree, rendered as a Starlight sidebar group.
*
* A folder is a place in the tree rather than a page, so the group carries no
* link of its own: a source tree that ships an index file for the folder
* contributes a page (see `nestPages`), and otherwise the pages inside it are
* the links.
*/
export interface SidebarGroupEntry {
label: string;
items: SidebarDocItem[];
/** Folders start open; Starlight remembers what a reader collapses. */
collapsed?: boolean;
}

export type SidebarDocItem = SidebarSlugEntry | SidebarGroupEntry;

/**
* One documentation version, exposed as its own Starlight topic.
*
Expand All @@ -19,34 +37,140 @@ export interface SidebarChannelTopic {
id: string;
label: string;
link: string;
items: SidebarSlugEntry[];
items: SidebarDocItem[];
}

/**
* Segments whose spelled-out form is wrong in a label: a folder called `json`
* is JSON, not "Json".
*/
const SEGMENT_UPPERCASE = new Set([
'aot',
'api',
'cli',
'http',
'https',
'json',
'mcp',
'nuget',
'sdk',
'sql',
'ui',
'url',
'xml',
'yaml',
]);

/**
* Display label for one slug segment: `compiled-validators` → `Compiled Validators`.
*
* A source folder has no title of its own — only its name — so a group label is
* the folder's segment spelled out, exactly as `extractTitle` spells out a file
* name when a page has no heading.
*/
export function docSectionLabel(segment: string): string {
return segment
.split(/[-_]+/)
.filter((word) => word !== '')
.map((word) =>
SEGMENT_UPPERCASE.has(word)
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1),
)
.join(' ');
}

/**
* Display labels of the folders a page lives in, outermost first:
* `schemas/custom-rules` → `['Schemas']`.
*
* The page itself is not part of the trail; this names the sections above it, so
* a page can say where in the tree it lives as well as which project it belongs
* to.
*/
export function docSectionTrail(slug: string): string[] {
return slug
.split('/')
.slice(0, -1)
.filter((segment) => segment !== '')
.map(docSectionLabel);
}

/** Content-relative base for a channel: `docs/<id>` or `docs/<id>/<channel>`. */
function channelBase(projectId: string, channel: DocsManifestChannel): string {
return channel.default ? `docs/${projectId}` : `docs/${projectId}/${channel.id}`;
}

/**
* Slug of the content entry a page is published as.
*
* An `index` page addresses the directory that holds it — Starlight strips the
* `index` segment, so `schemas/index.md` is served at `<base>/schemas`, not at
* `<base>/schemas/index`. Starlight prefixes the locale dir itself.
*/
function entrySlug(base: string, slug: string): string {
const segments = slug.split('/');
if (segments[segments.length - 1] === 'index') {
return [base, ...segments.slice(0, -1)].join('/');
}
return `${base}/${slug}`;
}

/**
* Arrange a channel's pages into the folder tree their slugs describe.
*
* Since ADR 0011 a slug is a path (`schemas/custom-rules`), so the hierarchy a
* project publishes is already in the data: the sidebar renders it instead of
* flattening it into one list of thirty links. A folder appears where its first
* page appears and its pages keep the order the channel gives them (`docs.order`
* or `_Sidebar.md`), which is what keeps the sync's reading order intact.
*
* A flat corpus has no folder in any slug, so it produces no group here: the
* entries are exactly the page list, in order, as before.
*/
function nestPages(pages: DocsManifestChannel['pages'], base: string): SidebarDocItem[] {
const items: SidebarDocItem[] = [];
// Keyed by the folder's full path (`schemas`, `guides/getting-started`), never
// by its own name, so two folders may share a leaf name at different depths.
const groups = new Map<string, SidebarGroupEntry>();
for (const page of pages) {
const segments = page.slug.split('/');
let siblings = items;
let path = base;
for (const folder of segments.slice(0, -1)) {
path = `${path}/${folder}`;
let group = groups.get(path);
if (!group) {
group = { label: docSectionLabel(folder), items: [] };
groups.set(path, group);
siblings.push(group);
}
siblings = group.items;
}
siblings.push({ label: page.title, slug: entrySlug(base, page.slug) });
}
return items;
}

/**
* Items for one channel: an Overview entry when the channel has an index page,
* then one entry per page. Index pages resolve to the directory slug (no
* trailing `/index`); Starlight prefixes the locale dir itself.
* then one entry per page, nested by the folders its slug names.
*/
export function buildChannelItems(
projectId: string,
channel: DocsManifestChannel,
): SidebarSlugEntry[] {
): SidebarDocItem[] {
const base = channelBase(projectId, channel);
const items: SidebarSlugEntry[] = [];
const items: SidebarDocItem[] = [];
if (channel.pages.some((page) => page.slug === 'index')) {
items.push({ label: 'Overview', slug: base });
}
for (const page of channel.pages) {
if (page.slug === 'index') {
continue;
}
items.push({ label: page.title, slug: `${base}/${page.slug}` });
}
items.push(
...nestPages(
channel.pages.filter((page) => page.slug !== 'index'),
base,
),
);
return items;
}

Expand Down
Loading
Loading