diff --git a/AGENTS.md b/AGENTS.md index 1a47e9d..552ab6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,14 +87,19 @@ prints advisory observations (which never fail the build) when a project's decla 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//`, the other is served at `/docs///`, and `docsPage` resolves against the default channel. + Each channel is a first-class docs set — its own sidebar topic (` `), its own + `/projects///llms*.txt` bundle, and its own entry on the docs portal — because the + same product at another version is different documentation. 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. - Agent Skills a project publishes are declared in `skills:` (ADR 0010) at `skills//SKILL.md`; the sync mirrors the whole directory to - `/skills///` and every page renders the same install block - (`npx skills add /`). Never ship a skill in a package or copy it + `/skills///` and each project page renders the same install block + (npm, bun, pnpm and yarn runners) plus that project's skill list with the + descriptions from each `SKILL.md`. `/skills/` redirects to `/projects/`. + Never ship a skill in a package or copy it into an agent directory at build time, and never hand-edit `src/public/skills/`. - `order` values are unique and control the catalogue order. - `related`, `supersedes`, `supersededBy` must reference known ids. diff --git a/README.md b/README.md index 003ef06..0724716 100644 --- a/README.md +++ b/README.md @@ -316,9 +316,12 @@ docs: ``` The `live` channel is the default and keeps the canonical `/docs//` -URLs; the `prerelease` channel is served at `/docs//prerelease/`. The -sidebar groups pages per channel, and every page offers a switcher. Only -`github-path` sources support two channels. +URLs; the `prerelease` channel is served at `/docs//prerelease/`. +Versions are first-class: each published channel is its own sidebar topic +(`ZodSharp 2.4.2`, `ZodSharp 3.0.0-prerelease.2`) with its own page list, its +own machine-readable bundle, and its own entry on the documentation portal — +same product, different version, therefore different docs. Every page also +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 @@ -333,18 +336,23 @@ config to declare). ## Agent skills Purview projects publish Agent Skills at `skills//SKILL.md` and deliver -them with the skills CLI (`npx skills add /`); nothing ships a -skill in a NuGet package. The catalogue declares the skills a project authors, -`just data-sync` mirrors each whole skill directory to `public/skills// -/…`, and the site presents them two ways (ADR 0010): - -- `/skills/` lists every skill grouped by project, with the install instructions - and a direct link to each `SKILL.md`; -- `/skills.json` is the machine-readable index (install commands, URLs, files). +them with the skills CLI (`npx`, `bunx`, `pnpm dlx` or `yarn dlx` — every runner +is offered); nothing ships a skill in a NuGet package. The catalogue declares the +skills a project authors, `just data-sync` mirrors each whole skill directory to +`public/skills///…`, and the site presents them in project +context (ADR 0010): + +- each project page lists that project's skills with the description from its + own `SKILL.md` and a direct link to the file; +- `/skills.json` is the machine-readable index (install commands per package + manager, URLs, files); +- `/skills/` redirects to `/projects/`, because a skill only makes sense next to + the project that authors it. The install block is one component (`components/SkillInstall.astro`), rendered -unchanged on `/skills/` and on every project page that declares skills, so the -instructions are identical across the suite: +unchanged on every project page that declares skills, so the instructions are +identical across the suite. Each command is an Expressive Code block, so it +carries the copy button: ```yaml skills: @@ -379,6 +387,10 @@ build-time. - Without a cache, the site falls back to committed fixtures under `src/fixtures/releases/` and labels the data source (`live` / `cache` / `fixture`) in the UI. +- A package that has only prereleases is still listed: the stable cell reads `—` + and the prerelease cell carries the version. Package membership comes from the + catalogue (`packages:`), so a new package is one manifest entry — the release + data supplies its versions, downloads and deprecation state. - `just refresh-fixtures` regenerates the committed test fixtures from live sources (review the diff before committing). @@ -392,8 +404,13 @@ 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 per-project scope. Documentation versions are first-class, so a second +published channel gets its own pair under its channel segment +(`/projects///llms.txt`, `/projects///llms-full.txt`): +one corpus never mixes two versions of the same page. Every bundle names its +version and links the other versions' bundles. The project page, each +documentation version's overview page, the documentation portal, `discover.json` +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 diff --git a/docs/decisions/0009-documentation-channels.md b/docs/decisions/0009-documentation-channels.md index 365ac78..dd52263 100644 --- a/docs/decisions/0009-documentation-channels.md +++ b/docs/decisions/0009-documentation-channels.md @@ -149,12 +149,20 @@ history has to survive between builds, and an existing page is never overwritten 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. +- The sidebar treats each channel as a **topic of its own**: `ZodSharp 2.4.2` and + `ZodSharp 3.0.0-prerelease.2` are separate entries in the topic picker, each + with a flat page list, exactly as two projects would be. A channel is a whole + documentation set, not a section of one, so grouping both under a single + project topic would claim one tree while the pages belong to two. A + single-channel project keeps the bare project name. +- Every published version has its own machine-readable bundle. The default + channel keeps `/projects//llms.txt` and `llms-full.txt`; any other channel + mirrors its documentation URL shape at + `/projects///llms.txt` and `llms-full.txt`. Each bundle carries a + version block (version, channel, source ref) and links the other versions' + bundles, and never mixes two versions of one page in a single corpus. + `discover.json` describes each version under `docsChannels`, and the sitemaps + list every version's bundles. - `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 @@ -177,5 +185,6 @@ history has to survive between builds, and an existing page is never overwritten search engines and from `llms.txt`, and make `/docs///` 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. +- **Keeping both channels in one `llms-full.txt`.** Rejected: one corpus should + not contain two versions of the same page. Each channel publishes its own + `/projects/[/]/llms*.txt` bundle instead. diff --git a/docs/decisions/0010-agent-skills.md b/docs/decisions/0010-agent-skills.md index 2a60edb..1d1c302 100644 --- a/docs/decisions/0010-agent-skills.md +++ b/docs/decisions/0010-agent-skills.md @@ -37,15 +37,24 @@ nothing on another) and could not read a skill without cloning its repository. to `public/skills///…` and caches it under `.cache/skills/`, so `https://purview.dev/skills///SKILL.md` (and its references) is a real, stable URL. `public/skills/` is generated and - gitignored like `public/schemas/`. + gitignored like `public/schemas/`. A skill's description is read from its own + YAML front matter — inline or a folded block scalar (`description: >-`) — so + what the site shows is what the CLI shows. 3. **One shared instruction block, everywhere.** A single component - (`components/SkillInstall.astro`) renders the command, the `#` pinning - syntax and the `skills-lock.json` restore note. It is used unchanged on - `/skills/`, on each project page that declares skills, and in - `/skills.json` — so the instructions cannot drift between projects. -4. **`/skills/` is the browse and install page**, grouped by project, and - `/skills.json` is the machine-readable index (name, description, directory, - entry URL, file list, install commands). + (`components/SkillInstall.astro`) renders the install commands, the `#` + pinning syntax and the `skills-lock.json` restore note. Every command is an + Expressive Code block (so it carries the copy button), and every package + manager a reader may use is offered — `npx`, `bunx`, `pnpm dlx`, `yarn dlx` — + rather than telling a bun or pnpm user to reach for npm. The component is used + unchanged on each project page that declares skills and in `/skills.json`, so + the instructions cannot drift. +4. **Skills are presented in project context.** Each project page lists the + skills that project authors, with the description from each `SKILL.md` and a + direct link to the file; the catalogue cards show how many skills a project + has. `/skills.json` remains the machine-readable index (name, description, + directory, entry URL, file list, install commands per package manager), and + `/skills/` redirects to `/projects/` — a site-wide list separated a skill from + the project that gives it meaning. 5. **Guards, not hopes.** `just check-projects` fails when a declared skill has no mirror, `just check-generated` fails when a declared skill is missing from the built site, and the sync fails when a declared skill resolves to no files @@ -53,9 +62,9 @@ nothing on another) and could not read a skill without cloning its repository. ## Consequences -- `pm-skills` gains fourteen declarations; `aspire-resourcekit` and `results` gain - five each, so the same install block now appears on their project pages and on - `/skills/` with their own repositories. +- `pm-skills` gains its own declarations; `aspire-resourcekit` and `results` gain + five each, so the same install block now appears on each of their project pages + with its own repository. - The site never claims to *host* the CLI source: it serves the skill files. The CLI still installs from the git repository, which is what records a pinnable ref; the portal URLs exist so a skill can be read, reviewed or fetched without diff --git a/docs/discovery.md b/docs/discovery.md index cdf3757..f61c1ec 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -26,12 +26,21 @@ release cache). **There is no hand-maintained list of URLs.** | Site map (JSON) | `/discover.json` | integration (`lib/discovery/manifest.ts`) | | Root LLM content | `/llms.txt`, `/llms-small.txt`, `/llms-full.txt` | `starlight-llms-txt` | | Per-project LLM content | `/projects//llms.txt`, `/projects//llms-full.txt` | integration (`lib/discovery/project-llms.ts`) | +| Per-version LLM content | `/projects///llms.txt`, `/projects///llms-full.txt` | integration (`lib/discovery/project-llms.ts`) | | IndexNow key file | `/.txt` (written from the secret) | deploy workflow via `bun run discovery:key` | | Internal deployment state | `src/.cache/discovery/state.json` (never published) | integration | The integration lives in `src/config/discovery-integration.ts`; the shared model is in `src/src/lib/discovery/`. +A project that publishes more than one documentation version (ADR 0009) gets one +bundle pair **per version**. The default channel keeps the historical +`/projects//llms*.txt` paths; every other channel adds its id as a path +segment, mirroring its `/docs///` URLs. Each bundle names the +version it documents and links the other versions' bundles, and no corpus +contains two versions of the same page. `discover.json` exposes every version +under `docsChannels` (schemaVersion 2). + ### Sitemap partitioning - **pages.xml** — hand-authored pages (`/`, `/about/`, `/docs/`, `/projects/`, @@ -181,6 +190,7 @@ Automated (nothing to do per deploy): - [x] `robots.txt` generated and references the sitemap - [x] Root `llms.txt` / `llms-full.txt` generated - [x] Per-project `llms.txt` / `llms-full.txt` generated +- [x] Per-version `llms.txt` / `llms-full.txt` generated for every published channel - [x] `discover.json` generated - [x] IndexNow key file written from the secret by the deploy workflow - [x] IndexNow notifies changed URLs after a successful deploy @@ -203,6 +213,8 @@ One-time (operator): - [ ] Verify the first IndexNow deployment - [ ] Verify a representative project LLM resource (`https://purview.dev/projects/zodsharp/llms.txt`) +- [ ] Verify a representative versioned LLM resource while a second channel is + published (`https://purview.dev/projects/zodsharp/prerelease/llms.txt`) ## Security diff --git a/src/astro.config.ts b/src/astro.config.ts index 700e791..e3b9726 100644 --- a/src/astro.config.ts +++ b/src/astro.config.ts @@ -12,9 +12,10 @@ 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 { projectChannelLlmsUrl, projectLlmsUrl } from './src/lib/discovery/urls'; import { readDocsManifest } from './src/lib/docs/aggregate'; -import { buildProjectItems } from './src/lib/docs/sidebar'; +import { channelDisplayLabel, channelTopicLabel } from './src/lib/docs/channels'; +import { buildProjectTopics } 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'; @@ -53,12 +54,18 @@ const topicBadges: Partial ({ - label: project.name, - link: `/docs/${project.id}/`, - badge: topicBadges[displayStatus(project)], - items: buildProjectItems(project, docsManifest), - })), + // Each published documentation version is its own topic (ADR 0009): the same + // product at a different version is a different documentation set, with its + // own pages, its own sidebar and its own machine-readable bundle. A project + // with one channel therefore produces exactly the topic it always did. + ...docsProjects.flatMap((project) => + buildProjectTopics(project, docsManifest).map((topic) => ({ + label: topic.label, + link: topic.link, + badge: topicBadges[displayStatus(project)], + items: topic.items, + })), + ), ]; const HEAD = [ @@ -215,13 +222,30 @@ export default defineConfig({ url: absoluteUrl('/releases/'), description: 'GitHub and NuGet release information.', }, - // Per-project machine-readable bundles, emitted by the discovery - // integration at /projects//llms.txt. - ...docsProjects.map((project) => ({ - label: project.name, - url: projectLlmsUrl(project.id), - description: project.shortDescription, - })), + // Per-version machine-readable bundles, emitted by the discovery + // integration at /projects/[/]/llms.txt. Each published + // documentation version advertises its own bundle, so a model can + // read the version it needs instead of one corpus with two versions + // of the same page. + ...docsProjects.flatMap((project) => { + const channels = + docsManifest?.projects.find((entry) => entry.projectId === project.id)?.channels ?? + []; + if (channels.length === 0) { + return [ + { + label: project.name, + url: projectLlmsUrl(project.id), + description: project.shortDescription, + }, + ]; + } + return channels.map((channel) => ({ + label: channelTopicLabel(project.name, channel, channels.length), + url: projectChannelLlmsUrl(project.id, channel.id, channel.default), + description: `${project.shortDescription} (${channelDisplayLabel(channel)})`, + })); + }), ], promote: ['index*', 'docs/*/index'], demote: [], diff --git a/src/config/docs-redirects-integration.ts b/src/config/docs-redirects-integration.ts index ed0d4f1..c85fa7b 100644 --- a/src/config/docs-redirects-integration.ts +++ b/src/config/docs-redirects-integration.ts @@ -3,36 +3,36 @@ import type { AstroIntegration } from 'astro'; import { fileURLToPath } from 'node:url'; import { readDocsManifest } from '../src/lib/docs/aggregate'; -import { writeRetiredChannelRedirects } from '../src/lib/docs/redirects'; +import { writeRetiredChannelRedirects, writeRetiredPageRedirects } from '../src/lib/docs/redirects'; import { loadProjects } from '../src/lib/manifest/load'; /** - * Redirects for retired documentation channels (ADR 0009). + * Redirects for retired documentation channels and moved pages (ADR 0009, + * ADR 0010). * * A project's `prerelease` channel is withheld automatically once a stable - * release catches up with it, so its URLs must not 404. purview.dev is served - * from GitHub Pages, which only serves files, so each retired URL is written as - * a meta-refresh page pointing at the live page with the same slug. + * release catches up with it, and `/skills/` moved to the project pages, so + * neither set of URLs may 404. purview.dev is served from GitHub Pages, which + * only serves files, so each retired URL is written as a meta-refresh page + * pointing at its new home. * * Runs at `astro:build:done` beside the discovery integration. It deliberately * writes outside Astro's route list, so the redirects never enter the sitemap, * `discover.json` or the llms bundles, and they are marked `noindex` and * `data-pagefind-ignore` so they never become search results. Existing files are - * never overwritten — a published channel owns its URLs. + * never overwritten — a published channel or page owns its URLs. */ export default function docsRedirectsIntegration(): AstroIntegration { return { name: 'purview-docs-redirects', hooks: { 'astro:build:done': ({ dir, logger }) => { - const written = writeRetiredChannelRedirects( - fileURLToPath(dir), - loadProjects(), - readDocsManifest(), - ); - if (written.length > 0) { + const dist = fileURLToPath(dir); + const retired = writeRetiredChannelRedirects(dist, loadProjects(), readDocsManifest()); + const moved = writeRetiredPageRedirects(dist); + if (retired.length + moved.length > 0) { logger.info( - `Redirects: ${written.length} retired documentation URL(s) now point at the live release.`, + `Redirects: ${retired.length} retired documentation URL(s) and ${moved.length} moved page(s) now point at their new home.`, ); } }, diff --git a/src/fixtures/nuget/nuspec/purview.zodsharp.aspnetcore.openapi.nuspec b/src/fixtures/nuget/nuspec/purview.zodsharp.aspnetcore.openapi.nuspec new file mode 100644 index 0000000..52bdc9f --- /dev/null +++ b/src/fixtures/nuget/nuspec/purview.zodsharp.aspnetcore.openapi.nuspec @@ -0,0 +1,27 @@ + + + + Purview.ZodSharp.AspNetCore.OpenApi + 3.0.0-prerelease.2 + ZodSharp Contributors,Purview-Dev Contributors + MIT + https://licenses.nuget.org/MIT + purview-logo-light.png + README.md + https://purview.dev/projects/zodsharp/ + OpenAPI transformers for ASP.NET Core that describe the constraints, formats, unions and error contract ZodSharp enforces. + zod validation aspnetcore openapi jsonschema + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/fixtures/nuget/nuspec/purview.zodsharp.blazor.nuspec b/src/fixtures/nuget/nuspec/purview.zodsharp.blazor.nuspec new file mode 100644 index 0000000..e1404fa --- /dev/null +++ b/src/fixtures/nuget/nuspec/purview.zodsharp.blazor.nuspec @@ -0,0 +1,29 @@ + + + + Purview.ZodSharp.Blazor + 3.0.0-prerelease.2 + ZodSharp Contributors,Purview-Dev Contributors + MIT + https://licenses.nuget.org/MIT + purview-logo-light.png + README.md + https://purview.dev/projects/zodsharp/ + Blazor integration for ZodSharp: validates EditForm models with ZodSharp on Blazor Server, Blazor + WebAssembly and Auto render mode, through the standard DataAnnotationsValidator or the full-fidelity + ZodValidator, and maps API validation problems back onto form fields. + zod validation schema blazor webassembly forms + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/fixtures/nuget/nuspec/purview.zodsharp.extensions.validation.nuspec b/src/fixtures/nuget/nuspec/purview.zodsharp.extensions.validation.nuspec new file mode 100644 index 0000000..d342f7d --- /dev/null +++ b/src/fixtures/nuget/nuspec/purview.zodsharp.extensions.validation.nuspec @@ -0,0 +1,23 @@ + + + + Purview.ZodSharp.Extensions.Validation + 3.0.0-prerelease.2 + ZodSharp Contributors,Purview-Dev Contributors + MIT + https://licenses.nuget.org/MIT + purview-logo-light.png + README.md + https://purview.dev/projects/zodsharp/ + Microsoft.Extensions.Validation integration for ZodSharp: registers ZodSharp validators as the first + validation resolver, so minimal APIs, Blazor Server and Blazor WebAssembly validate with ZodSharp. + zod validation schema blazor minimalapi + + + + + + + + + \ No newline at end of file diff --git a/src/fixtures/nuget/nuspec/purview.zodsharp.http.nuspec b/src/fixtures/nuget/nuspec/purview.zodsharp.http.nuspec new file mode 100644 index 0000000..4af1663 --- /dev/null +++ b/src/fixtures/nuget/nuspec/purview.zodsharp.http.nuspec @@ -0,0 +1,30 @@ + + + + Purview.ZodSharp.Http + 3.0.0-prerelease.2 + ZodSharp Contributors,Purview-Dev Contributors + MIT + https://licenses.nuget.org/MIT + purview-logo-light.png + README.md + https://purview.dev/projects/zodsharp/ + HttpClient integration for ZodSharp: validates request bodies before they are sent and response bodies as they + are read, and reads the validation problems Purview.ZodSharp.AspNetCore writes back into ZodSharp errors. + Trim-, Native AOT- and Blazor WebAssembly-safe. + zod validation schema http httpclient problemdetails webassembly + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/fixtures/nuget/purview.zodsharp.aspnetcore.openapi.json b/src/fixtures/nuget/purview.zodsharp.aspnetcore.openapi.json new file mode 100644 index 0000000..8ec497f --- /dev/null +++ b/src/fixtures/nuget/purview.zodsharp.aspnetcore.openapi.json @@ -0,0 +1,5 @@ +{ + "versions": [ + "3.0.0-prerelease.2" + ] +} diff --git a/src/fixtures/nuget/purview.zodsharp.blazor.json b/src/fixtures/nuget/purview.zodsharp.blazor.json new file mode 100644 index 0000000..8ec497f --- /dev/null +++ b/src/fixtures/nuget/purview.zodsharp.blazor.json @@ -0,0 +1,5 @@ +{ + "versions": [ + "3.0.0-prerelease.2" + ] +} diff --git a/src/fixtures/nuget/purview.zodsharp.extensions.validation.json b/src/fixtures/nuget/purview.zodsharp.extensions.validation.json new file mode 100644 index 0000000..8ec497f --- /dev/null +++ b/src/fixtures/nuget/purview.zodsharp.extensions.validation.json @@ -0,0 +1,5 @@ +{ + "versions": [ + "3.0.0-prerelease.2" + ] +} diff --git a/src/fixtures/nuget/purview.zodsharp.http.json b/src/fixtures/nuget/purview.zodsharp.http.json new file mode 100644 index 0000000..8ec497f --- /dev/null +++ b/src/fixtures/nuget/purview.zodsharp.http.json @@ -0,0 +1,5 @@ +{ + "versions": [ + "3.0.0-prerelease.2" + ] +} diff --git a/src/fixtures/nuget/search/Purview.ZodSharp.AspNetCore.OpenApi.json b/src/fixtures/nuget/search/Purview.ZodSharp.AspNetCore.OpenApi.json new file mode 100644 index 0000000..b6b988b --- /dev/null +++ b/src/fixtures/nuget/search/Purview.ZodSharp.AspNetCore.OpenApi.json @@ -0,0 +1,8 @@ +{ + "@context": { + "@vocab": "http://schema.nuget.org/schema#", + "@base": "https://api.nuget.org/v3/registration5-semver1/" + }, + "totalHits": 0, + "data": [] +} diff --git a/src/fixtures/nuget/search/Purview.ZodSharp.Blazor.json b/src/fixtures/nuget/search/Purview.ZodSharp.Blazor.json new file mode 100644 index 0000000..b6b988b --- /dev/null +++ b/src/fixtures/nuget/search/Purview.ZodSharp.Blazor.json @@ -0,0 +1,8 @@ +{ + "@context": { + "@vocab": "http://schema.nuget.org/schema#", + "@base": "https://api.nuget.org/v3/registration5-semver1/" + }, + "totalHits": 0, + "data": [] +} diff --git a/src/fixtures/nuget/search/Purview.ZodSharp.Extensions.Validation.json b/src/fixtures/nuget/search/Purview.ZodSharp.Extensions.Validation.json new file mode 100644 index 0000000..b6b988b --- /dev/null +++ b/src/fixtures/nuget/search/Purview.ZodSharp.Extensions.Validation.json @@ -0,0 +1,8 @@ +{ + "@context": { + "@vocab": "http://schema.nuget.org/schema#", + "@base": "https://api.nuget.org/v3/registration5-semver1/" + }, + "totalHits": 0, + "data": [] +} diff --git a/src/fixtures/nuget/search/Purview.ZodSharp.Http.json b/src/fixtures/nuget/search/Purview.ZodSharp.Http.json new file mode 100644 index 0000000..b6b988b --- /dev/null +++ b/src/fixtures/nuget/search/Purview.ZodSharp.Http.json @@ -0,0 +1,8 @@ +{ + "@context": { + "@vocab": "http://schema.nuget.org/schema#", + "@base": "https://api.nuget.org/v3/registration5-semver1/" + }, + "totalHits": 0, + "data": [] +} diff --git a/src/fixtures/releases/index.json b/src/fixtures/releases/index.json index ba60f73..b1b8e63 100644 --- a/src/fixtures/releases/index.json +++ b/src/fixtures/releases/index.json @@ -3712,6 +3712,47 @@ "net10.0" ], "targetFrameworksVersion": "1.0.0-prerelease.6" + }, + "Purview.ZodSharp.Http": { + "id": "Purview.ZodSharp.Http", + "versions": [ + "3.0.0-prerelease.2" + ], + "targetFrameworks": [ + "net10.0", + "net11.0" + ], + "targetFrameworksVersion": "3.0.0-prerelease.2" + }, + "Purview.ZodSharp.Blazor": { + "id": "Purview.ZodSharp.Blazor", + "versions": [ + "3.0.0-prerelease.2" + ], + "targetFrameworks": [ + "net11.0" + ], + "targetFrameworksVersion": "3.0.0-prerelease.2" + }, + "Purview.ZodSharp.AspNetCore.OpenApi": { + "id": "Purview.ZodSharp.AspNetCore.OpenApi", + "versions": [ + "3.0.0-prerelease.2" + ], + "targetFrameworks": [ + "net11.0" + ], + "targetFrameworksVersion": "3.0.0-prerelease.2" + }, + "Purview.ZodSharp.Extensions.Validation": { + "id": "Purview.ZodSharp.Extensions.Validation", + "versions": [ + "3.0.0-prerelease.2" + ], + "targetFrameworks": [ + "net11.0" + ], + "targetFrameworksVersion": "3.0.0-prerelease.2" } }, "packageSearch": { @@ -4041,6 +4082,10 @@ "Purview.Containers.MySql": null, "Purview.Containers.RabbitMq": null, "Purview.Containers.Azurite": null, - "Purview.Containers.Nats": null + "Purview.Containers.Nats": null, + "Purview.ZodSharp.Http": null, + "Purview.ZodSharp.Blazor": null, + "Purview.ZodSharp.AspNetCore.OpenApi": null, + "Purview.ZodSharp.Extensions.Validation": null } } diff --git a/src/scripts/check-generated.ts b/src/scripts/check-generated.ts index bcb3688..fbf64da 100644 --- a/src/scripts/check-generated.ts +++ b/src/scripts/check-generated.ts @@ -236,8 +236,8 @@ function validateProjectAssets(): void { /** * Every declared Agent Skill is mirrored from its repository into `public/` and - * copied into the built site (ADR 0010), so a missing mirror means `/skills/` - * and the project page link to a 404. + * copied into the built site (ADR 0010), so a missing mirror means the project + * page and `/skills.json` link to a 404. */ function validateProjectSkills(): void { for (const project of loadProjects()) { diff --git a/src/scripts/discovery/validate.ts b/src/scripts/discovery/validate.ts index e8180c1..aab9031 100644 --- a/src/scripts/discovery/validate.ts +++ b/src/scripts/discovery/validate.ts @@ -10,6 +10,8 @@ import { ROBOTS_PATH, SITEMAP_INDEX_PATH, isCanonicalUrl, + projectChannelLlmsFullUrl, + projectChannelLlmsUrl, projectLlmsFullUrl, projectLlmsUrl, projectPageUrl, @@ -206,6 +208,10 @@ export function validateDiscovery( slug?: string; url?: string; llms?: { summary?: string; full?: string } | null; + docsChannels?: { + docs?: string; + llms?: { summary?: string; full?: string }; + }[]; }[]; }; if (manifest.schemaVersion !== DISCOVERY_MANIFEST_SCHEMA_VERSION) { @@ -229,20 +235,45 @@ export function validateDiscovery( fail(`discover.json project LLM URL is off-origin: ${url}`); } } + // Each published documentation version is its own docs site, so its + // docs and bundle URLs must be canonical too. + for (const channel of project.docsChannels ?? []) { + for (const url of [channel.docs, channel.llms?.summary, channel.llms?.full]) { + if (url && !isCanonicalUrl(url, origin)) { + fail(`discover.json documentation version URL is off-origin: ${url}`); + } + } + } } } catch { fail('discover.json is not valid JSON.'); } } - // --- per-project llms resources ----------------------------------------- - for (const project of documentedProjects(loadDiscoveryContext())) { - for (const url of [projectLlmsUrl(project.id), projectLlmsFullUrl(project.id)]) { + // --- per-version llms resources ----------------------------------------- + // Every published documentation version has its own bundle pair: the default + // channel at `/projects//llms*.txt`, every other channel under its + // segment (`/projects///llms*.txt`). A missing bundle means the + // docs and the machine-readable representation disagree about what exists. + const context = loadDiscoveryContext(); + for (const project of documentedProjects(context)) { + const entry = context.docsManifest?.projects.find( + (candidate) => candidate.projectId === project.id, + ); + const channels = entry?.channels ?? []; + const urls = + channels.length > 0 + ? channels.flatMap((channel) => [ + projectChannelLlmsUrl(project.id, channel.id, channel.default), + projectChannelLlmsFullUrl(project.id, channel.id, channel.default), + ]) + : [projectLlmsUrl(project.id), projectLlmsFullUrl(project.id)]; + for (const url of new Set(urls)) { const content = readDistByUrl(url); if (content === null) { - fail(`Missing per-project LLM resource: ${url}`); + fail(`Missing per-version LLM resource: ${url}`); } else if (content.trim().length === 0) { - fail(`Per-project LLM resource is empty: ${url}`); + fail(`Per-version LLM resource is empty: ${url}`); } } } diff --git a/src/src/components/ProjectCard.astro b/src/src/components/ProjectCard.astro index d3d4968..34f1e6d 100644 --- a/src/src/components/ProjectCard.astro +++ b/src/src/components/ProjectCard.astro @@ -4,6 +4,7 @@ import { buildBadge, nugetDownloadsBadge, nugetVersionBadge, actionsUrl } from ' import { projectRepoEnrichment } from '~/lib/releases/repo'; import { getReleaseIndex } from '~/lib/releases/runtime'; import type { ResolvedProject } from '~/lib/manifest/load'; +import { listMirroredSkills } from '~/lib/skills'; import { displayStatus } from '~/lib/status'; import { nugetPackageUrl, withBase } from '~/lib/urls'; @@ -16,6 +17,7 @@ interface Props { const { project, showBadges = true } = Astro.props; const status = displayStatus(project); const { tags } = projectRepoEnrichment(project, getReleaseIndex().data); +const skillCount = listMirroredSkills(project.id).length; const primaryPackage = project.packages.find((pkg) => pkg.primary) ?? project.packages[0]; const badges = status === 'archived' ? @@ -59,6 +61,13 @@ const badges = ) } + { + skillCount > 0 && ( + + {skillCount} skill{skillCount === 1 ? '' : 's'} + + ) + } { tags.slice(0, 4).map((tag) => ( diff --git a/src/src/components/SiteNav.astro b/src/src/components/SiteNav.astro index fe57e11..539237c 100644 --- a/src/src/components/SiteNav.astro +++ b/src/src/components/SiteNav.astro @@ -17,7 +17,6 @@ export const NAV_ITEMS: readonly NavItem[] = [ { label: 'Projects', href: '/projects/' }, { label: 'Use cases', href: '/use-cases/' }, { label: 'Documentation', href: '/docs/' }, - { label: 'Skills', href: '/skills/' }, { label: 'Releases', href: '/releases/' }, { label: 'About', href: '/about/' }, ]; diff --git a/src/src/components/SkillInstall.astro b/src/src/components/SkillInstall.astro index 5ee139e..f5ca340 100644 --- a/src/src/components/SkillInstall.astro +++ b/src/src/components/SkillInstall.astro @@ -1,12 +1,13 @@ --- -import { skillEntryPath, skillsInstallCommand } from '~/lib/skills'; +import { Code } from '@astrojs/starlight/components'; +import { skillEntryPath, skillsInstallCommands } from '~/lib/skills'; import { withBase } from '~/lib/urls'; interface Props { /** `owner/repository` the skills are published from. */ repository: string; projectId: string; - skills: { name: string; description?: string }[]; + skills: { name: string; description?: string | null }[]; /** Render the per-skill "fetch it directly" links (default: true). */ showLinks?: boolean; } @@ -16,9 +17,10 @@ const { repository, projectId, skills, showLinks = true } = Astro.props; // One set of instructions for every project that authors skills (ADR 0010). // The command, the pinning syntax and the lockfile note are identical wherever // this component renders, so a reader never meets two versions of the truth. -const command = skillsInstallCommand(repository); -const pinned = `${command}#`; -const restore = 'npx skills experimental_install'; +// Each runner is an Expressive Code block, so every command carries the copy +// button — a reader pastes it instead of retyping it. +const commands = skillsInstallCommands(repository); +const defaultCommand = commands[0]!; ---
@@ -30,25 +32,37 @@ const restore = 'npx skills experimental_install'; target="_blank" class="pv-link">skills CLI. Nothing ships them inside a NuGet package, and no build writes into your agent's directories: - installation is a decision you make. + installation is a decision you make. Use whichever package manager you already have.

-
{command}
-
    +
    + { + commands.map((entry) => ( +
    +

    + {entry.label} +

    + +
    + )) + } +
    +
    • Installs at project level by default. Add -g for a user-level install, or -a <agent> to target one agent.
    • - To match the release you use, pin a commit or tag: {pinned}. + To match the release you use, pin a commit or tag by appending #<ref> to + any command above (for example {defaultCommand.pinned}).
    • - The CLI records what it installed in skills-lock.json; restore it with - {restore}. + The CLI records what it installed in skills-lock.json; restore it with the same + runner:
    +
    + +
    { showLinks && skills.length > 0 && (

    diff --git a/src/src/components/SkillList.astro b/src/src/components/SkillList.astro new file mode 100644 index 0000000..a4103bd --- /dev/null +++ b/src/src/components/SkillList.astro @@ -0,0 +1,34 @@ +--- +import { skillEntryPath } from '~/lib/skills'; +import { withBase } from '~/lib/urls'; + +interface Props { + projectId: string; + skills: { name: string; description: string | null; files: number }[]; +} + +const { projectId, skills } = Astro.props; +--- + +

      + { + skills.map((skill) => ( +
    • +
      + + {skill.name} + + {skill.description && ( +

      {skill.description}

      + )} +
      + + {skill.files} file{skill.files === 1 ? '' : 's'} + +
    • + )) + } +
    diff --git a/src/src/components/starlight/Head.astro b/src/src/components/starlight/Head.astro index 286f368..ad23904 100644 --- a/src/src/components/starlight/Head.astro +++ b/src/src/components/starlight/Head.astro @@ -1,21 +1,35 @@ --- import DefaultHead from '@astrojs/starlight/components/Head.astro'; -import { projectLlmsFullUrl } from '~/lib/discovery/urls'; +import { readDocsManifest } from '~/lib/docs/aggregate'; +import { projectChannelLlmsFullUrl } from '~/lib/discovery/urls'; import { SITE } from '~/lib/site'; import { absoluteUrl } from '~/lib/urls'; // Documentation pages are aggregated from the product repositories. Expose the -// per-project machine-readable bundle as an alternate representation and a -// `TechArticle` JSON-LD block. Only accurate values are emitted; the canonical -// HTML page remains the primary content. +// machine-readable bundle of the version the page came from as an alternate +// representation and a `TechArticle` JSON-LD block. A project can publish more +// than one version (ADR 0009), and each version has its own bundle, so the link +// follows the page's channel rather than the project's default. Only accurate +// values are emitted; the canonical HTML page remains the primary content. const data = Astro.locals.starlightRoute?.entry?.data; const sourceProject = typeof data?.sourceProject === 'string' ? data.sourceProject : undefined; const hasMachineReadable = sourceProject !== undefined && data?.status !== 'archived'; +const docsChannel = typeof data?.docsChannel === 'string' ? data.docsChannel : undefined; +const channel = + sourceProject === undefined + ? undefined + : readDocsManifest() + ?.projects.find((project) => project.projectId === sourceProject) + ?.channels.find((candidate) => candidate.id === docsChannel); +const fullUrl = + sourceProject === undefined + ? null + : projectChannelLlmsFullUrl(sourceProject, channel?.id ?? 'live', channel?.default ?? true); const alternateLinks = - hasMachineReadable && sourceProject ? + hasMachineReadable && fullUrl ? [ { - href: projectLlmsFullUrl(sourceProject), + href: fullUrl, type: 'text/markdown', title: `${data?.title ?? sourceProject} — machine-readable documentation`, }, diff --git a/src/src/components/starlight/PageTitle.astro b/src/src/components/starlight/PageTitle.astro index 1002986..befeb26 100644 --- a/src/src/components/starlight/PageTitle.astro +++ b/src/src/components/starlight/PageTitle.astro @@ -5,6 +5,7 @@ import StatusBadge from '~/components/StatusBadge.astro'; import { readDocsManifest } from '~/lib/docs/aggregate'; import { channelBasePath, channelDisplayLabel } from '~/lib/docs/channels'; import { isStale, STALE_AFTER_DAYS } from '~/lib/docs/staleness'; +import { projectChannelLlmsFullPath, projectChannelLlmsPath } from '~/lib/discovery/urls'; import { LLMS_LINK_ATTRS } from '~/lib/llms'; import { getReleaseIndex } from '~/lib/releases/runtime'; import { displayStatus } from '~/lib/status'; @@ -39,20 +40,50 @@ const channelHref = (channelId: 'live' | 'prerelease', isDefault: boolean): stri const pinnedChannel = activeChannel?.refKind === 'tag'; const stale = lastReviewed && !pinnedChannel ? isStale(lastReviewed) : false; -// Project overview pages surface the per-project `llms.txt` bundle emitted by -// the discovery integration at /projects//llms.txt. Other aggregated pages -// keep the heading lean and only link the global files from the site footer. -const entryId = typeof entry?.id === 'string' ? entry.id : undefined; -const isProjectOverview = entryId !== undefined && /^docs\/[^/]+(\/index)?$/.test(entryId); -const sourceProject = typeof data?.sourceProject === 'string' ? data.sourceProject : undefined; +// A channel's landing page advertises that version's machine-readable bundles. +// A project can publish more than one version (ADR 0009), and each version is a +// whole docs set with its own `llms.txt`, so the link follows the channel the +// reader is on rather than the project's default channel. Other aggregated +// pages keep the heading lean and only link the global files from the site +// footer. const sourceRepo = typeof data?.sourceRepo === 'string' ? data.sourceRepo : undefined; +// A channel's landing page is the one page whose URL is exactly the channel +// base path; comparing paths keeps this independent of how Starlight names the +// content entry. +const channelPath = + sourceProjectId !== undefined && activeChannel + ? withBase(channelBasePath(sourceProjectId, activeChannel.id, activeChannel.default)) + : undefined; +const isChannelIndex = + channelPath !== undefined && + Astro.url.pathname.replace(/\/+$/, '') === channelPath.replace(/\/+$/, ''); +const versionLabel = activeChannel + ? `${data?.projectName ?? sourceProjectId} ${activeChannel.label}` + : ''; +const llmsLinks = + isChannelIndex && sourceProjectId && activeChannel + ? [ + { + label: 'llms.txt', + href: withBase( + projectChannelLlmsPath(sourceProjectId, activeChannel.id, activeChannel.default), + ), + title: `Machine-readable index for ${versionLabel}`, + }, + { + label: 'llms-full.txt', + href: withBase( + projectChannelLlmsFullPath(sourceProjectId, activeChannel.id, activeChannel.default), + ), + title: `Full machine-readable documentation for ${versionLabel}`, + }, + ] + : []; // Live star count for the owning repository, when the release cache knows it. const starCount = sourceRepo === undefined ? null : (getReleaseIndex().data.repos[sourceRepo]?.stargazersCount ?? null); -const llmsHref = - isProjectOverview && sourceProject ? withBase(`/projects/${sourceProject}/llms.txt`) : null; ---

    {data?.title}

    @@ -171,18 +202,13 @@ const llmsHref = ) } { - llmsHref && ( + llmsLinks.map((link) => ( - - llms.txt + + {link.label} - ) + )) }
) diff --git a/src/src/components/starlight/TopicSelect.astro b/src/src/components/starlight/TopicSelect.astro index ae5c132..6d2e463 100644 --- a/src/src/components/starlight/TopicSelect.astro +++ b/src/src/components/starlight/TopicSelect.astro @@ -7,7 +7,7 @@ if (!isPageWithTopic) { ---