From 035ac4927de0d38854547318bbfcd1275a739704 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 18 Oct 2026 14:51:14 +0100 Subject: [PATCH] fix(docs): generate llms.txt narrative and correct per-project bundle paths --- .agents/agents/catalogue-onboarder.agent.md | 2 +- .agents/skills/add-catalogue-project/SKILL.md | 7 +- .agents/skills/add-project-intake/SKILL.md | 2 +- .../skills/docs-aggregation-rules/SKILL.md | 4 +- AGENTS.md | 4 +- README.md | 17 +++-- docs/decisions/0003-agent-tooling.md | 4 +- docs/decisions/0004-experimental-projects.md | 2 +- package.json | 2 +- src/astro.config.ts | 12 ++-- src/scripts/check-projects.ts | 2 +- src/src/lib/llms-details.ts | 47 +++++++++++++ src/src/lib/llms.ts | 3 +- src/tests/dist/built-output.test.ts | 10 +++ src/tests/unit/llms-details.test.ts | 70 +++++++++++++++++++ 15 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 src/src/lib/llms-details.ts create mode 100644 src/tests/unit/llms-details.test.ts diff --git a/.agents/agents/catalogue-onboarder.agent.md b/.agents/agents/catalogue-onboarder.agent.md index 3345df9..bea933d 100644 --- a/.agents/agents/catalogue-onboarder.agent.md +++ b/.agents/agents/catalogue-onboarder.agent.md @@ -62,7 +62,7 @@ The most important rules are: discussions only when `discussions: true` is declared. 6. **Integrate and prove** — run `just data-sync`, then `just check-projects`, `bun run typecheck`, `bun run test`, `just check-generated`, and finally `just validate`. Confirm the docs page count, - the sidebar topic, the `/_llms-txt/.txt` bundle, and the catalogue/use-case cards. + the sidebar topic, the `/projects//llms.txt` bundle, and the catalogue/use-case cards. 7. **Report** — list the record added, the evidence gathered (docs pages, packages, release channel), the answers received (and any assumption taken where the human was unavailable), the checks run, and anything that still needs a human (for example, no stable release yet, or docs diff --git a/.agents/skills/add-catalogue-project/SKILL.md b/.agents/skills/add-catalogue-project/SKILL.md index 6aca2be..f728454 100644 --- a/.agents/skills/add-catalogue-project/SKILL.md +++ b/.agents/skills/add-catalogue-project/SKILL.md @@ -68,8 +68,8 @@ description/topics. channel alongside it (`preview` for prerelease-only tooling). See ADR 0004; the project page and every aggregated documentation page then carry an Experimental warning. - `id`: lowercase `[a-z0-9-]` slug, normally the repository name. -- `name`: display name. **It must slugify to `id` when the project has `docs`** (the LLM bundle is - emitted at `/_llms-txt/.txt` while the UI links `/_llms-txt/.txt`). +- `name`: display name. **It must slugify to `id` when the project has `docs`** (so the display name + and the `/projects//` and `/docs//` URLs agree). - `order`: pick an unused value (10–80 in tens is the existing convention). ## Step 3 — author the record @@ -150,7 +150,8 @@ Confirm each surface actually lights up: 1. `/projects/` shows the card with the right category/status/tags (tags come from topics). 2. `/projects//` renders description, use cases, install panel, packages, and related links. 3. `/docs//` exists with the expected page count and a sidebar topic named after the project. -4. `/_llms-txt/.txt` is emitted and linked from `/llms.txt`. +4. `/projects//llms.txt` and `/projects//llms-full.txt` are emitted and linked from + `/llms.txt`. 5. `/use-cases/` lists the new use cases under the right audiences. 6. `/releases/` shows GitHub/NuGet versions for the declared packages. diff --git a/.agents/skills/add-project-intake/SKILL.md b/.agents/skills/add-project-intake/SKILL.md index cf37a1f..82c3515 100644 --- a/.agents/skills/add-project-intake/SKILL.md +++ b/.agents/skills/add-project-intake/SKILL.md @@ -50,7 +50,7 @@ decides. "Required" means stop and ask; "confirm" means propose a value and let | --- | --- | --- | --- | | 1 | Is this repository owned by `purview-dev`, or is it a collaboration? **Required only when the owner is ambiguous** (a fork, a shared org, a repo the org contributes to but does not own). | `projects:` vs `externalProjects:` | Owner from `gh api`. Only `purview-dev/*` may go in `projects:`; collaborations get a link-out card and no docs aggregation. | | 2 | The `id` is normally the repository name. **If a `purview.dev/projects//` URL is already published** (NuGet `projectUrl`, repository homepage), the id is committed — changing it breaks a live link. Confirm before choosing a different id. | `id` | Repository name. Lowercase `[a-z0-9-]`. | -| 3 | For any project with `docs`, the display `name` must slugify to exactly `id` (the LLM bundle is emitted at `/_llms-txt/.txt` while the UI links `/_llms-txt/.txt`). **Required whenever the natural display name does not slugify to the id** — e.g. "Architecture Testing" → `architecture-testing`, which is not `architests`. Ask which side moves: rename the display name, or change the id (and the URL). | `name` | A display name that slugifies to `id`; must not start with `Purview `. | +| 3 | For any project with `docs`, the display `name` must slugify to exactly `id` (so the display name and the `/projects//` and `/docs//` URLs agree). **Required whenever the natural display name does not slugify to the id** — e.g. "Architecture Testing" → `architecture-testing`, which is not `architests`. Ask which side moves: rename the display name, or change the id (and the URL). | `name` | A display name that slugifies to `id`; must not start with `Purview `. | ### Classification (judgement — confirm, never pick silently) diff --git a/.agents/skills/docs-aggregation-rules/SKILL.md b/.agents/skills/docs-aggregation-rules/SKILL.md index 882209b..7037428 100644 --- a/.agents/skills/docs-aggregation-rules/SKILL.md +++ b/.agents/skills/docs-aggregation-rules/SKILL.md @@ -81,8 +81,8 @@ H1s are demoted to keep one title per document. `src/.cache/docs/index.json` (so the mirror and the manifest must come from the same sync). - 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 `/_llms-txt/.txt` (built from `rawContent`), linked from - `/llms.txt` and from the project page. +- Every documented project gets `/projects//llms.txt` and `/projects//llms-full.txt`, linked + from `/llms.txt` and from the project page. - Staleness: a page is flagged when `lastReviewed` is older than 365 days (`src/src/lib/docs/staleness.ts`). diff --git a/AGENTS.md b/AGENTS.md index dfdc62e..91aa985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,8 +75,8 @@ prints advisory observations (which never fail the build) when a project's decla `just check-projects`: - `id` is a lowercase `[a-z0-9-]` slug; `repository` is `owner/repository` and must be `purview-dev/*` for `projects:` entries. - - For any project with `docs`, the display `name` must slugify to exactly `id` (the per-project - LLM bundle is emitted at `/_llms-txt/.txt` from the slugified name). + - For any project with `docs`, the display `name` must slugify to exactly `id` (so the display + name and the `/projects//` and `/docs//` URLs agree). - NuGet package ids are unique across the whole catalogue; when a project has more than one package, exactly one must be `primary: true`. - Every non-archived project needs at least one concrete use case; a use case with `code` must diff --git a/README.md b/README.md index e0e1798..85012c7 100644 --- a/README.md +++ b/README.md @@ -332,13 +332,16 @@ The build generates `/llms.txt`, `/llms-small.txt`, and `/llms-full.txt` (Starlight's `starlight-llms-txt` plugin) using canonical production URLs and the aggregated documentation. -Each documented project is also emitted as its own scoped bundle at -`/_llms-txt/.txt` via the plugin's `customSets` option. The project -page, the project's documentation overview page, and the documentation portal -all link to that path, and `/llms.txt` lists every bundle under -`Documentation Sets`. Because `rawContent` is enabled, each bundle is the raw -aggregated Markdown for that project (the same content that feeds -`llms-full.txt`, scoped down). +Each documented project is also emitted as its own scoped bundle by the +discovery integration (see [docs/discovery.md](docs/discovery.md)) at +`/projects//llms.txt` (a concise index) and `/projects//llms-full.txt` +(the project's aggregated documentation), following the `llms.txt` convention at +the per-project scope. The project page, the project's documentation overview +page, the documentation portal and `/llms.txt` all link to those paths. + +The suite-level narrative in `/llms.txt` is generated from the project catalogue +(`src/src/lib/llms-details.ts`) rather than authored by hand, so it cannot drift +from the projects the site actually ships. The built outputs are asserted by `tests/dist/` and `just check-generated`. diff --git a/docs/decisions/0003-agent-tooling.md b/docs/decisions/0003-agent-tooling.md index d03f14e..07d3606 100644 --- a/docs/decisions/0003-agent-tooling.md +++ b/docs/decisions/0003-agent-tooling.md @@ -10,8 +10,8 @@ Adding a project to the website touches several coupled surfaces: a typed manife NuGet package declarations that drive install snippets and release tables, GitHub repository topics that provide the site's tag chips, and concrete audience-tagged use cases. The catalogue also has implicit invariants that are easy to break by hand — for example the display `name` of a documented -project must slugify to its `id`, because `starlight-llms-txt` derives the per-project bundle path -from the project name while the UI links `/_llms-txt/.txt`. +project must slugify to its `id`, so the display name and the project's `/projects//` and +`/docs//` URLs agree. Two tasks recur and were previously tribal knowledge: diff --git a/docs/decisions/0004-experimental-projects.md b/docs/decisions/0004-experimental-projects.md index 565e838..d6ed838 100644 --- a/docs/decisions/0004-experimental-projects.md +++ b/docs/decisions/0004-experimental-projects.md @@ -63,7 +63,7 @@ combination is a conscious decision rather than an oversight. Only `archived` removes a project from the catalogue, the documentation portal, the sidebar topics and the per-project LLM bundles. An experimental project with documentation is aggregated, listed, -indexed by `/use-cases/`, included in `/releases/`, and gets its `/_llms-txt/.txt` bundle like +indexed by `/use-cases/`, included in `/releases/`, and gets its `/projects//llms.txt` bundle like any other project. Discoverability with an explicit warning is the point of the flag. ### 6. The flag is presented on the project page and on every documentation page diff --git a/package.json b/package.json index 642a810..1e0db2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-dev", - "version": "0.7.0", + "version": "0.7.1", "private": true, "description": "Purview-Dev public website and unified documentation portal (workspace root).", "license": "MIT", diff --git a/src/astro.config.ts b/src/astro.config.ts index baf3502..ad6b607 100644 --- a/src/astro.config.ts +++ b/src/astro.config.ts @@ -14,6 +14,7 @@ import { remarkMermaid } from './config/remark-mermaid.mjs'; import { projectLlmsUrl } from './src/lib/discovery/urls'; import { readDocsManifest } from './src/lib/docs/aggregate'; import { buildProjectItems } from './src/lib/docs/sidebar'; +import { buildLlmsDetails } from './src/lib/llms-details'; import { loadProjects } from './src/lib/manifest/load'; import { SITE, BRAND } from './src/lib/site'; import { displayStatus, type DisplayStatus } from './src/lib/status'; @@ -182,12 +183,12 @@ export default defineConfig({ errorOnRelativeLinks: false, }), starlightLlmsTxt({ - projectName: 'Purview Dev', + projectName: SITE.fullName, description: SITE.description, - details: [ - 'Purview Dev builds developer tooling for .NET: event sourcing, telemetry, validation, source generators, and build automation. The site combines a project catalogue, release information, and unified documentation aggregated from the product repositories.', - 'See the projects catalogue for the full product family, the releases page for GitHub and NuGet version information, and the documentation portal for per-project guides.', - ].join('\n\n'), + // The suite-level narrative is generated from the catalogue (see + // `src/lib/llms-details.ts`) rather than authored here, so it cannot + // drift from the projects the site actually ships. + details: buildLlmsDetails(projects), optionalLinks: [ { label: 'GitHub organisation', @@ -219,7 +220,6 @@ export default defineConfig({ ], promote: ['index*', 'docs/*/index'], demote: [], - exclude: ['dotnet-logging-source-generators/**'], minify: { note: true, tip: true, diff --git a/src/scripts/check-projects.ts b/src/scripts/check-projects.ts index 543a34a..b293d12 100644 --- a/src/scripts/check-projects.ts +++ b/src/scripts/check-projects.ts @@ -134,7 +134,7 @@ function checkDocs( observed: project.name, expected: `a display name that slugifies to "${project.id}"`, remediation: - 'rename the project (or its id) so the per-project LLM bundle at /_llms-txt/.txt resolves.', + 'rename the project (or its id) so the display name and the /projects// URL slug agree.', }); } const docsProject = docsManifest?.projects.find((entry) => entry.projectId === project.id); diff --git a/src/src/lib/llms-details.ts b/src/src/lib/llms-details.ts new file mode 100644 index 0000000..9ebebb2 --- /dev/null +++ b/src/src/lib/llms-details.ts @@ -0,0 +1,47 @@ +import type { ResolvedProject } from './manifest/load'; + +/** `1 project` / `2 projects`, so generated prose never reads "1 projects". */ +function plural(count: number, singular: string, pluralForm = `${singular}s`): string { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +/** + * The suite-level narrative for the root `/llms.txt`, derived from the project + * catalogue so it can never drift from the products the site actually ships. + * + * The text names only the projects present in the catalogue, groups them by + * their declared `category` and reports the counts. Nothing here is authored by + * hand: adding, archiving, renaming or recategorising a project changes the + * generated narrative on the next build. + */ +export function buildLlmsDetails(projects: readonly ResolvedProject[]): string { + const active = projects + .filter((project) => project.status !== 'archived') + .toSorted((a, b) => a.order - b.order || a.name.localeCompare(b.name)); + + const byCategory = new Map(); + for (const project of active) { + const names = byCategory.get(project.category) ?? []; + names.push(project.name); + byCategory.set(project.category, names); + } + + const groups = [...byCategory.entries()] + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([category, names]) => `${category.replace(/-/g, ' ')} (${names.join(', ')})`) + .join('; '); + + const documented = active.filter((project) => project.docs).length; + + const lead = + active.length === 0 + ? 'Purview Dev builds open-source .NET developer tooling.' + : `Purview Dev builds open-source .NET developer tooling across ${plural(active.length, 'project')} in ${plural(byCategory.size, 'category', 'categories')}: ${groups}.`; + + const documentation = + documented === 0 + ? 'per-project guides' + : `per-project guides (${plural(documented, 'documented project')})`; + + return `${lead}\n\nSee the projects catalogue for the full product family, the releases page for GitHub and NuGet version information, and the documentation portal for ${documentation}.`; +} diff --git a/src/src/lib/llms.ts b/src/src/lib/llms.ts index 1883ddb..f9c2651 100644 --- a/src/src/lib/llms.ts +++ b/src/src/lib/llms.ts @@ -1,7 +1,8 @@ /** * Link attributes for the generated LLM text bundles — `llms.txt`, * `llms-small.txt`, `llms-full.txt`, and the per-project - * `/_llms-txt/.txt` sets. + * `/projects//llms.txt` + `/projects//llms-full.txt` + * bundles. * * These are plain-text files rather than site pages, so every link to one is * treated like an external link and opens in a new tab. Spreading the object diff --git a/src/tests/dist/built-output.test.ts b/src/tests/dist/built-output.test.ts index 81a5d80..38a04ff 100644 --- a/src/tests/dist/built-output.test.ts +++ b/src/tests/dist/built-output.test.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { LOCAL_PATH_PATTERNS, SECRET_PATTERNS } from '../../src/lib/build-paths'; +import { buildLlmsDetails } from '../../src/lib/llms-details'; import { loadProjects } from '../../src/lib/manifest/load'; import { compareNuGetVersions } from '../../src/lib/nuget-version'; import { getReleaseIndex } from '../../src/lib/releases/runtime'; @@ -32,6 +33,15 @@ describe('built llms outputs', () => { expect(content).toContain('https://github.com/purview-dev'); }); + test('llms.txt suite narrative is generated from the catalogue', () => { + const content = requireBuilt('llms.txt'); + // The narrative must be exactly what the generator produces, so it cannot + // drift from the published projects or be hand-edited back into the config. + for (const paragraph of buildLlmsDetails(loadProjects()).split('\n\n')) { + expect(content, `llms.txt is missing generated paragraph: ${paragraph}`).toContain(paragraph); + } + }); + test('llms-full.txt includes aggregated documentation content', () => { const content = requireBuilt('llms-full.txt'); expect(content.trim().length).toBeGreaterThan(10_000); diff --git a/src/tests/unit/llms-details.test.ts b/src/tests/unit/llms-details.test.ts new file mode 100644 index 0000000..0642eaf --- /dev/null +++ b/src/tests/unit/llms-details.test.ts @@ -0,0 +1,70 @@ +import type { ResolvedProject } from '../../src/lib/manifest/load'; + +import { describe, expect, test } from 'bun:test'; + +import { buildLlmsDetails } from '../../src/lib/llms-details'; +import { loadProjects } from '../../src/lib/manifest/load'; + +/** Load a real catalogue record so the synthetic cases carry realistic defaults. */ +function fixtureProject(id: string): ResolvedProject { + const found = loadProjects().find((candidate) => candidate.id === id); + if (!found) { + throw new Error(`Missing fixture project: ${id}`); + } + return found; +} + +function project(overrides: Partial): ResolvedProject { + return { ...fixtureProject('zodsharp'), ...overrides }; +} + +describe('buildLlmsDetails', () => { + test('names every active project and no archived one', () => { + const details = buildLlmsDetails(loadProjects()); + for (const candidate of loadProjects()) { + if (candidate.status === 'archived') { + expect(details, `${candidate.id} is archived`).not.toContain(candidate.name); + } else { + expect(details, `${candidate.id} is active`).toContain(candidate.name); + } + } + }); + + test('reports the catalogue counts', () => { + const details = buildLlmsDetails(loadProjects()); + expect(details).toContain('Purview Dev builds'); + expect(details).toMatch(/\d+ projects in \d+ categor/); + }); + + test('groups projects by their declared category', () => { + const details = buildLlmsDetails([ + project({ id: 'alpha', name: 'Alpha', category: 'validation', status: 'stable', order: 10 }), + project({ id: 'beta', name: 'Beta', category: 'validation', status: 'stable', order: 20 }), + project({ + id: 'gamma', + name: 'Gamma', + category: 'build-tooling', + status: 'preview', + order: 30, + }), + ]); + expect(details).toContain('validation (Alpha, Beta)'); + expect(details).toContain('build tooling (Gamma)'); + expect(details).toContain('3 projects in 2 categories'); + }); + + test('excludes archived projects from the narrative', () => { + const details = buildLlmsDetails([ + project({ id: 'alpha', name: 'Alpha', status: 'stable', order: 10 }), + project({ id: 'old', name: 'Old Thing', status: 'archived', order: 20 }), + ]); + expect(details).not.toContain('Old Thing'); + expect(details).toContain('1 project in 1 category'); + }); + + test('handles an empty catalogue', () => { + const details = buildLlmsDetails([]); + expect(details).toContain('Purview Dev builds open-source .NET developer tooling.'); + expect(details).not.toContain('categories'); + }); +});