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
2 changes: 1 addition & 1 deletion .agents/agents/catalogue-onboarder.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.txt` bundle, and the catalogue/use-case cards.
the sidebar topic, the `/projects/<id>/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
Expand Down
7 changes: 4 additions & 3 deletions .agents/skills/add-catalogue-project/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug(name)>.txt` while the UI links `/_llms-txt/<id>.txt`).
- `name`: display name. **It must slugify to `id` when the project has `docs`** (so the display name
and the `/projects/<id>/` and `/docs/<id>/` URLs agree).
- `order`: pick an unused value (10–80 in tens is the existing convention).

## Step 3 — author the record
Expand Down Expand Up @@ -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/<id>/` renders description, use cases, install panel, packages, and related links.
3. `/docs/<id>/` exists with the expected page count and a sidebar topic named after the project.
4. `/_llms-txt/<id>.txt` is emitted and linked from `/llms.txt`.
4. `/projects/<id>/llms.txt` and `/projects/<id>/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.

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/add-project-intake/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/` 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/<slug(name)>.txt` while the UI links `/_llms-txt/<id>.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/<id>/` and `/docs/<id>/` 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)

Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/docs-aggregation-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.txt` (built from `rawContent`), linked from
`/llms.txt` and from the project page.
- Every documented project gets `/projects/<id>/llms.txt` and `/projects/<id>/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`).

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.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/<id>/` and `/docs/<id>/` 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
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project-id>.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/<id>/llms.txt` (a concise index) and `/projects/<id>/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`.

Expand Down
4 changes: 2 additions & 2 deletions docs/decisions/0003-agent-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.txt`.
project must slugify to its `id`, so the display name and the project's `/projects/<id>/` and
`/docs/<id>/` URLs agree.

Two tasks recur and were previously tribal knowledge:

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/0004-experimental-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.txt` bundle like
indexed by `/use-cases/`, included in `/releases/`, and gets its `/projects/<id>/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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 6 additions & 6 deletions src/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -219,7 +220,6 @@ export default defineConfig({
],
promote: ['index*', 'docs/*/index'],
demote: [],
exclude: ['dotnet-logging-source-generators/**'],
minify: {
note: true,
tip: true,
Expand Down
2 changes: 1 addition & 1 deletion src/scripts/check-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.txt resolves.',
'rename the project (or its id) so the display name and the /projects/<id>/ URL slug agree.',
});
}
const docsProject = docsManifest?.projects.find((entry) => entry.projectId === project.id);
Expand Down
47 changes: 47 additions & 0 deletions src/src/lib/llms-details.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();
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}.`;
}
3 changes: 2 additions & 1 deletion src/src/lib/llms.ts
Original file line number Diff line number Diff line change
@@ -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/<project-id>.txt` sets.
* `/projects/<project-id>/llms.txt` + `/projects/<project-id>/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
Expand Down
10 changes: 10 additions & 0 deletions src/tests/dist/built-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions src/tests/unit/llms-details.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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');
});
});
Loading