From 3a419f5db8b60896270b47890657406c5c96f37f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 14:01:15 +0000 Subject: [PATCH 1/7] feat(prose): render inline and fenced code in project text --- src/src/components/InlineProse.astro | 14 ++ src/src/components/ProjectCard.astro | 3 +- src/src/components/Prose.astro | 32 +++++ src/src/components/SkillList.astro | 5 +- src/src/components/UseCaseCard.astro | 14 +- src/src/components/starlight/PageTitle.astro | 3 +- src/src/lib/prose.ts | 129 +++++++++++++++++++ src/src/pages/docs/index.astro | 5 +- src/src/pages/projects/[project].astro | 19 +-- src/src/pages/projects/index.astro | 5 +- src/src/styles/global.css | 17 +++ src/tests/unit/prose.test.ts | 91 +++++++++++++ 12 files changed, 318 insertions(+), 19 deletions(-) create mode 100644 src/src/components/InlineProse.astro create mode 100644 src/src/components/Prose.astro create mode 100644 src/src/lib/prose.ts create mode 100644 src/tests/unit/prose.test.ts diff --git a/src/src/components/InlineProse.astro b/src/src/components/InlineProse.astro new file mode 100644 index 0000000..32d6e60 --- /dev/null +++ b/src/src/components/InlineProse.astro @@ -0,0 +1,14 @@ +--- +import { parseInlineCode, type ProseInline } from '~/lib/prose'; + +interface Props { + /** Single-line manifest text; `code` spans render as inline code. */ + text?: string; + /** Pre-parsed segments, when the caller already split a paragraph. */ + inlines?: ProseInline[]; +} + +const { text = '', inlines = parseInlineCode(text) } = Astro.props; +--- + +{inlines.map((part) => (part.code ? {part.text} : part.text))} diff --git a/src/src/components/ProjectCard.astro b/src/src/components/ProjectCard.astro index 070993f..53b045e 100644 --- a/src/src/components/ProjectCard.astro +++ b/src/src/components/ProjectCard.astro @@ -1,4 +1,5 @@ --- +import InlineProse from '~/components/InlineProse.astro'; import StatusBadge from '~/components/StatusBadge.astro'; import { buildBadge, nugetDownloadsBadge, nugetVersionBadge, actionsUrl } from '~/lib/badges'; import { projectRepoEnrichment } from '~/lib/releases/repo'; @@ -51,7 +52,7 @@ const badges =

{project.name}

-

{project.shortDescription}

+

{project.category} { diff --git a/src/src/components/Prose.astro b/src/src/components/Prose.astro new file mode 100644 index 0000000..df95aa7 --- /dev/null +++ b/src/src/components/Prose.astro @@ -0,0 +1,32 @@ +--- +import CodeSample from '~/components/CodeSample.astro'; +import InlineProse from '~/components/InlineProse.astro'; +import { parseProse } from '~/lib/prose'; + +interface Props { + /** Manifest prose: paragraphs, `inline code` and fenced code blocks. */ + text: string; + class?: string; +} + +const { text, class: className } = Astro.props; +const blocks = parseProse(text); +// The optional `lead` slot (e.g. "What you get:") opens the first paragraph so +// it reads as part of the sentence; it gets its own line if prose opens with code. +const hasLead = Astro.slots.has('lead'); +const leadInline = hasLead && blocks[0]?.kind === 'paragraph'; +--- + +
+ {hasLead && !leadInline &&

} + { + blocks.map((block, index) => + block.kind === 'code' ? + + :

+ {index === 0 && leadInline && } + +

, + ) + } +
diff --git a/src/src/components/SkillList.astro b/src/src/components/SkillList.astro index a4103bd..b67aa2b 100644 --- a/src/src/components/SkillList.astro +++ b/src/src/components/SkillList.astro @@ -1,4 +1,5 @@ --- +import InlineProse from '~/components/InlineProse.astro'; import { skillEntryPath } from '~/lib/skills'; import { withBase } from '~/lib/urls'; @@ -22,7 +23,9 @@ const { projectId, skills } = Astro.props; {skill.name} {skill.description && ( -

{skill.description}

+

+ +

)}
diff --git a/src/src/components/UseCaseCard.astro b/src/src/components/UseCaseCard.astro index d8e6962..ff7f874 100644 --- a/src/src/components/UseCaseCard.astro +++ b/src/src/components/UseCaseCard.astro @@ -1,5 +1,7 @@ --- import CodeSample from '~/components/CodeSample.astro'; +import InlineProse from '~/components/InlineProse.astro'; +import Prose from '~/components/Prose.astro'; import { AUDIENCE_LABELS, type ProjectUseCase } from '~/lib/manifest/schema'; interface Props { @@ -28,19 +30,19 @@ const docsLink = {showProject && {projectName}} -

{useCase.title}

+

-

{useCase.scenario}

+ {useCase.code && } -

- What you get: {useCase.outcome} -

+ + What you get: + { useCase.evidence && ( -

{useCase.evidence}

+ ) } diff --git a/src/src/components/starlight/PageTitle.astro b/src/src/components/starlight/PageTitle.astro index 5517714..4a01ce9 100644 --- a/src/src/components/starlight/PageTitle.astro +++ b/src/src/components/starlight/PageTitle.astro @@ -1,5 +1,6 @@ --- import GitHubIcon from '~/components/GitHubIcon.astro'; +import InlineProse from '~/components/InlineProse.astro'; import StarOnGitHub from '~/components/StarOnGitHub.astro'; import StatusBadge from '~/components/StatusBadge.astro'; import { readDocsManifest } from '~/lib/docs/aggregate'; @@ -104,7 +105,7 @@ const starCount = ) } -

{data?.title}

+

{ showChannelSwitcher && activeChannel && ( diff --git a/src/src/lib/prose.ts b/src/src/lib/prose.ts new file mode 100644 index 0000000..7668d8a --- /dev/null +++ b/src/src/lib/prose.ts @@ -0,0 +1,129 @@ +/** + * Manifest prose (`description`, `origin`, use-case `scenario`, …) is plain + * text that may carry Markdown code: `inline` spans and fenced blocks. The + * project pages render those two constructs and nothing else, so a field reads + * the same on the site as it does in the YAML without opening the door to + * arbitrary Markdown or HTML. + */ + +export type ProseInline = { code: boolean; text: string }; + +export type ProseBlock = + | { kind: 'paragraph'; inlines: ProseInline[] } + | { kind: 'code'; code: string; language?: string }; + +/** An opening fence: three or more backticks or tildes, then an optional language. */ +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)[^`]*$/; + +/** + * Split text into plain and inline-code segments following CommonMark code + * spans: a run of N backticks closes at the next run of exactly N, line endings + * inside a span become spaces, and one padding space is stripped from each side + * when both are present. A run with no partner stays literal text. + */ +export function parseInlineCode(text: string): ProseInline[] { + const parts: ProseInline[] = []; + let plain = ''; + let index = 0; + while (index < text.length) { + const start = text.indexOf('`', index); + if (start === -1) { + plain += text.slice(index); + break; + } + let end = start; + while (end < text.length && text[end] === '`') { + end += 1; + } + const length = end - start; + const close = findClosingRun(text, end, length); + if (close === -1) { + plain += text.slice(index, end); + index = end; + continue; + } + plain += text.slice(index, start); + if (plain) { + parts.push({ code: false, text: plain }); + plain = ''; + } + let code = text.slice(end, close).replace(/\r?\n/g, ' '); + if (code.length > 1 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') { + code = code.slice(1, -1); + } + parts.push({ code: true, text: code }); + index = close + length; + } + if (plain) { + parts.push({ code: false, text: plain }); + } + return parts; +} + +/** Index of the next backtick run of exactly `length` at or after `from`, or -1. */ +function findClosingRun(text: string, from: number, length: number): number { + let index = from; + while (index < text.length) { + const start = text.indexOf('`', index); + if (start === -1) { + return -1; + } + let end = start; + while (end < text.length && text[end] === '`') { + end += 1; + } + if (end - start === length) { + return start; + } + index = end; + } + return -1; +} + +/** + * Split prose into paragraphs (separated by blank lines) and fenced code + * blocks. A fence closes on a line of the same character at least as long as + * the opener; an unclosed fence runs to the end of the text, as in CommonMark. + */ +export function parseProse(source: string): ProseBlock[] { + const blocks: ProseBlock[] = []; + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + let paragraph: string[] = []; + + const flushParagraph = () => { + const text = paragraph.join('\n').trim(); + if (text) { + blocks.push({ kind: 'paragraph', inlines: parseInlineCode(text) }); + } + paragraph = []; + }; + + let i = 0; + while (i < lines.length) { + const line = lines[i] ?? ''; + const open = FENCE_OPEN.exec(line); + if (open) { + flushParagraph(); + const fence = open[1] ?? '```'; + const language = open[2] || undefined; + const close = new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`); + const body: string[] = []; + i += 1; + while (i < lines.length && !close.test(lines[i] ?? '')) { + body.push(lines[i] ?? ''); + i += 1; + } + blocks.push({ kind: 'code', code: body.join('\n'), language }); + i += 1; + continue; + } + if (line.trim() === '') { + flushParagraph(); + } else { + paragraph.push(line); + } + i += 1; + } + flushParagraph(); + return blocks; +} diff --git a/src/src/pages/docs/index.astro b/src/src/pages/docs/index.astro index f24697a..c165f9e 100644 --- a/src/src/pages/docs/index.astro +++ b/src/src/pages/docs/index.astro @@ -1,5 +1,6 @@ --- import SiteLayout from '~/layouts/SiteLayout.astro'; +import InlineProse from '~/components/InlineProse.astro'; import { LLMS_LINK_ATTRS } from '~/lib/llms'; import { loadProjects } from '~/lib/manifest/load'; import { defaultDocsChannel, readDocsManifest } from '~/lib/docs/aggregate'; @@ -57,7 +58,9 @@ const pageCount = docsManifest?.projects.reduce( class="pv-card pv-card-interactive flex flex-col gap-3 p-5 no-underline" >

{project.name}

-

{project.shortDescription}

+

+ +

{ tags.length > 0 && (
diff --git a/src/src/pages/projects/[project].astro b/src/src/pages/projects/[project].astro index 0cde7c1..8e1b664 100644 --- a/src/src/pages/projects/[project].astro +++ b/src/src/pages/projects/[project].astro @@ -1,6 +1,8 @@ --- import SiteLayout from '~/layouts/SiteLayout.astro'; import GitHubIcon from '~/components/GitHubIcon.astro'; +import InlineProse from '~/components/InlineProse.astro'; +import Prose from '~/components/Prose.astro'; import SkillInstall from '~/components/SkillInstall.astro'; import SkillList from '~/components/SkillList.astro'; import InstallPanel from '~/components/islands/InstallPanel.tsx'; @@ -212,7 +214,7 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({

{project.name}

-

{project.shortDescription}

+

{ tags.length > 0 && ( @@ -419,7 +421,7 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({

The problem

-

{project.description}

+ { supplementalRepoDescription && (
@@ -436,7 +438,7 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({

Why it became a project

-

{project.origin}

+
{ @@ -458,9 +460,10 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({ {acknowledgment.name} {acknowledgment.description && ( -

- {acknowledgment.description} -

+ )} ))} @@ -474,11 +477,11 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({

Use it when

-

{project.useWhen}

+

It may not fit when

-

{project.avoidWhen}

+
diff --git a/src/src/pages/projects/index.astro b/src/src/pages/projects/index.astro index 9e1d501..3223349 100644 --- a/src/src/pages/projects/index.astro +++ b/src/src/pages/projects/index.astro @@ -1,5 +1,6 @@ --- import SiteLayout from '~/layouts/SiteLayout.astro'; +import InlineProse from '~/components/InlineProse.astro'; import ProjectCard from '~/components/ProjectCard.astro'; import ProjectFilter from '~/components/islands/ProjectFilter.tsx'; import StatusBadge from '~/components/StatusBadge.astro'; @@ -73,7 +74,9 @@ const statuses = ['stable', 'preview', 'experimental'];

{project.name}

{project.status && }
-

{project.shortDescription}

+

+ +

{project.category} External ↗ diff --git a/src/src/styles/global.css b/src/src/styles/global.css index 0190a55..8faccce 100644 --- a/src/src/styles/global.css +++ b/src/src/styles/global.css @@ -296,6 +296,23 @@ padding-left: 0.75rem; } + /* Manifest prose (``): paragraphs and fenced code blocks stacked with + * a consistent gap, and `inline code` spans that match the Markdown the + * fields are written in. */ + .pv-prose > * + * { + margin-top: 0.75rem; + } + + .pv-inline-code { + border-radius: 0.25rem; + background-color: var(--color-code-bg); + padding: 0.0625rem 0.3125rem; + font-family: var(--font-mono); + font-size: 0.875em; + color: var(--color-foreground); + overflow-wrap: anywhere; + } + /* Chips are single-line labels: a squeezed flex row must shrink the content * beside a chip, never wrap the chip's own label onto a second line (which * made the status chip grow two lines tall next to a card title). */ diff --git a/src/tests/unit/prose.test.ts b/src/tests/unit/prose.test.ts new file mode 100644 index 0000000..6963398 --- /dev/null +++ b/src/tests/unit/prose.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseInlineCode, parseProse } from '../../src/lib/prose'; + +describe('inline code', () => { + test('splits single-backtick spans from the surrounding text', () => { + expect(parseInlineCode('The C# 15 `union` keyword lowers to `object?`.')).toEqual([ + { code: false, text: 'The C# 15 ' }, + { code: true, text: 'union' }, + { code: false, text: ' keyword lowers to ' }, + { code: true, text: 'object?' }, + { code: false, text: '.' }, + ]); + }); + + test('a longer run can contain a backtick and strips one padding space', () => { + expect(parseInlineCode('use `` a`b `` here')).toEqual([ + { code: false, text: 'use ' }, + { code: true, text: 'a`b' }, + { code: false, text: ' here' }, + ]); + }); + + test('a run only closes on a run of the same length', () => { + expect(parseInlineCode('``a`b``')).toEqual([{ code: true, text: 'a`b' }]); + }); + + test('an unmatched backtick stays literal text', () => { + expect(parseInlineCode("it's a ` stray")).toEqual([{ code: false, text: "it's a ` stray" }]); + }); + + test('a span folded across lines reads as one line', () => { + expect(parseInlineCode('`is`/`null\npatterns`')).toEqual([ + { code: true, text: 'is' }, + { code: false, text: '/' }, + { code: true, text: 'null patterns' }, + ]); + }); +}); + +describe('prose blocks', () => { + test('a folded scalar is one paragraph', () => { + expect(parseProse('One `a` line.')).toEqual([ + { + kind: 'paragraph', + inlines: [ + { code: false, text: 'One ' }, + { code: true, text: 'a' }, + { code: false, text: ' line.' }, + ], + }, + ]); + }); + + test('blank lines separate paragraphs', () => { + expect(parseProse('First.\n\nSecond.').map((block) => block.kind)).toEqual([ + 'paragraph', + 'paragraph', + ]); + }); + + test('a fenced block keeps its lines and language', () => { + expect(parseProse('Before:\n\n```csharp\nvar x = 1;\n var y = 2;\n```\nAfter.')).toEqual([ + { kind: 'paragraph', inlines: [{ code: false, text: 'Before:' }] }, + { kind: 'code', code: 'var x = 1;\n var y = 2;', language: 'csharp' }, + { kind: 'paragraph', inlines: [{ code: false, text: 'After.' }] }, + ]); + }); + + test('a fence closes only on a run at least as long as the opener', () => { + expect(parseProse('~~~~\n```\n~~~\n~~~~')).toEqual([ + { kind: 'code', code: '```\n~~~', language: undefined }, + ]); + }); + + test('an unclosed fence runs to the end of the text', () => { + expect(parseProse('```\ncode')).toEqual([{ kind: 'code', code: 'code', language: undefined }]); + }); + + test('triple backticks inside a line are an inline span, not a fence', () => { + expect(parseProse('```x``` inline')).toEqual([ + { + kind: 'paragraph', + inlines: [ + { code: true, text: 'x' }, + { code: false, text: ' inline' }, + ], + }, + ]); + }); +}); From d874a8c80d8c77af87cffff447760eff4fdcc3a0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 14:01:29 +0000 Subject: [PATCH 2/7] fix(styles): keep Mermaid diagrams full size under reduced motion --- src/src/styles/global.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/src/styles/global.css b/src/src/styles/global.css index 8faccce..96c3666 100644 --- a/src/src/styles/global.css +++ b/src/src/styles/global.css @@ -143,6 +143,15 @@ *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; + } + + /* SVG content is excluded: Mermaid sets its diagram geometry and measures + * it in the same frame, so a non-zero transition on every SVG element makes + * it read the pre-transition values and lay the diagram out thousands of + * pixels wide, shrinking the rendered nodes to specks. */ + :where(*:not(svg, svg *)), + :where(*:not(svg *))::before, + :where(*:not(svg *))::after { transition-duration: 0.01ms !important; } } From 88515113463f2c72299b720de1f6f03c4dc5c33c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 14:01:31 +0000 Subject: [PATCH 3/7] fix(docs): stop showing each page title twice --- .../skills/docs-aggregation-rules/SKILL.md | 5 +- src/scripts/check-generated.ts | 11 ++++- src/src/lib/docs/frontmatter.ts | 48 +++++++++++-------- src/tests/dist/built-output.test.ts | 24 ++++++++++ src/tests/unit/docs.test.ts | 10 +++- 5 files changed, 72 insertions(+), 26 deletions(-) diff --git a/.agents/skills/docs-aggregation-rules/SKILL.md b/.agents/skills/docs-aggregation-rules/SKILL.md index 11db059..be1ddc6 100644 --- a/.agents/skills/docs-aggregation-rules/SKILL.md +++ b/.agents/skills/docs-aggregation-rules/SKILL.md @@ -153,8 +153,9 @@ Additional transformations: GitHub alert blockquotes become Starlight asides; re documents are rewritten to portal URLs; **relative** images are rewritten to GitHub raw URLs while **absolute** image URLs pass through untouched — a registry badge such as `![Purview.BuildSdk](https://img.shields.io/nuget/v/Purview.BuildSdk)` keeps its own URL, so a page can -show a live version without stating a number that rots; duplicate top-level H1s are demoted to keep one -title per document. +show a live version without stating a number that rots; the first top-level H1 is removed from the body +(it becomes the front-matter `title`, which the page header renders) and any later H1 is demoted to `##`, +so each page shows its title once. ## What the site does with it diff --git a/src/scripts/check-generated.ts b/src/scripts/check-generated.ts index fbf64da..58e8604 100644 --- a/src/scripts/check-generated.ts +++ b/src/scripts/check-generated.ts @@ -146,9 +146,16 @@ function validateMirrorPage(file: string, relativePath: string): void { if (!extractDescription(body)) { fail(`Generated Markdown has no readable prose: ${relativePath}.`); } + // The title lives in the front matter and the page header renders it; an H1 + // left in the body is the same title shown twice. const { titleCount, unclosedFence } = inspectMarkdownStructure(body); - if (titleCount !== 1) { - fail(`Generated Markdown must contain exactly one H1: ${relativePath} (${titleCount}).`); + if (!String(frontmatter.title ?? '').trim()) { + fail(`Generated Markdown has no title in its front matter: ${relativePath}.`); + } + if (titleCount !== 0) { + fail( + `Generated Markdown must not repeat its title as an H1 in the body: ${relativePath} (${titleCount}).`, + ); } if (unclosedFence) { fail(`Generated Markdown has an unclosed fenced block: ${relativePath}.`); diff --git a/src/src/lib/docs/frontmatter.ts b/src/src/lib/docs/frontmatter.ts index feed22d..c818be5 100644 --- a/src/src/lib/docs/frontmatter.ts +++ b/src/src/lib/docs/frontmatter.ts @@ -48,30 +48,38 @@ export function extractTitle(markdown: string, fileName: string): string { .join(' '); } -/** Keep one document title while preserving later source headings as sections. */ +/** + * Remove the document title and keep later source headings as sections. + * + * The first `# ` heading is the page title: it is written to the front matter + * (`extractTitle`) and the page header renders it, so leaving it in the body + * showed every aggregated page's title twice. Any later `# ` heading is demoted + * to `##` so the page keeps exactly one H1. + */ export function normalizeDocumentHeadings(markdown: string): string { let foundTitle = false; let fence: '```' | '~~~' | null = null; + const output: string[] = []; - return markdown - .split(/\r?\n/) - .map((line) => { - const fenceMatch = /^(\s*)(```|~~~)/.exec(line); - if (fenceMatch?.[2]) { - const marker = fenceMatch[2] as '```' | '~~~'; - fence = fence === marker ? null : (fence ?? marker); - return line; - } - if (fence || !/^#\s+\S/.test(line)) { - return line; - } - if (!foundTitle) { - foundTitle = true; - return line; - } - return `#${line}`; - }) - .join('\n'); + for (const line of markdown.split(/\r?\n/)) { + const fenceMatch = /^(\s*)(```|~~~)/.exec(line); + if (fenceMatch?.[2]) { + const marker = fenceMatch[2] as '```' | '~~~'; + fence = fence === marker ? null : (fence ?? marker); + output.push(line); + continue; + } + if (fence || !/^#\s+\S/.test(line)) { + output.push(line); + continue; + } + if (!foundTitle) { + foundTitle = true; + continue; + } + output.push(`#${line}`); + } + return output.join('\n').replace(/^\s*\n/, ''); } const FRONTMATTER_PATTERN = /^---\s*\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)/; diff --git a/src/tests/dist/built-output.test.ts b/src/tests/dist/built-output.test.ts index a89eb98..a8ab072 100644 --- a/src/tests/dist/built-output.test.ts +++ b/src/tests/dist/built-output.test.ts @@ -172,6 +172,30 @@ describe('built docs strip mkdocs markup', () => { }); }); +describe('built docs titles', () => { + // The aggregated Markdown's own `# Title` used to stay in the body under the + // page header that renders the same title, so every page showed it twice. + test('every aggregated page has exactly one h1', () => { + const offenders: string[] = []; + for (const project of readDocsManifest()?.projects ?? []) { + for (const channel of project.channels) { + const base = channel.default + ? ['docs', project.projectId] + : ['docs', project.projectId, channel.id]; + for (const page of channel.pages) { + const slug = page.slug === 'index' ? '' : page.slug.replace(/\/index$/, ''); + const file = [...base, slug, 'index.html'].filter(Boolean).join('/'); + const count = (requireBuilt(file).match(/]/g) ?? []).length; + if (count !== 1) { + offenders.push(`${file} (${count})`); + } + } + } + } + expect(offenders).toEqual([]); + }); +}); + describe('built agent skills', () => { /** * The published skill set is data, not site content: `just data-sync` diff --git a/src/tests/unit/docs.test.ts b/src/tests/unit/docs.test.ts index fd0c53f..c09a23a 100644 --- a/src/tests/unit/docs.test.ts +++ b/src/tests/unit/docs.test.ts @@ -190,10 +190,16 @@ describe('front matter', () => { ).toBe('The guide starts here.'); }); - test('demotes additional top-level headings without touching fenced examples', () => { + test('drops the title (the page header renders it) and demotes later top-level headings', () => { const source = '# POC-001\n\n## Context\n\n# Outcome\n\n```md\n# Example\n```'; expect(normalizeDocumentHeadings(source)).toBe( - '# POC-001\n\n## Context\n\n## Outcome\n\n```md\n# Example\n```', + '## Context\n\n## Outcome\n\n```md\n# Example\n```', + ); + }); + + test('drops a title that follows a badge line, keeping the badge', () => { + expect(normalizeDocumentHeadings('[![badge](b.svg)](x)\n\n# Title\n\nBody.')).toBe( + '[![badge](b.svg)](x)\n\n\nBody.', ); }); From 70144458f429fb75bdd7e67361f53884af97c1af Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 14:01:41 +0000 Subject: [PATCH 4/7] feat(docs): read repository-owned diagram pairs and _redirects.yml --- .../skills/docs-aggregation-rules/SKILL.md | 51 +++- .../project-manifest-reference/SKILL.md | 5 +- AGENTS.md | 8 +- ...repository-owned-diagrams-and-redirects.md | 66 ++++++ src/config/docs-redirects-integration.ts | 8 +- src/src/lib/docs/aggregate.ts | 185 ++++++++++++--- src/src/lib/docs/diagrams.ts | 222 ++++++++++++++++++ src/src/lib/docs/redirect-file.ts | 84 +++++++ src/src/lib/docs/redirects.ts | 68 ++++-- src/tests/unit/docs-diagrams.test.ts | 125 ++++++++++ src/tests/unit/docs-redirect-file.test.ts | 102 ++++++++ 11 files changed, 863 insertions(+), 61 deletions(-) create mode 100644 docs/decisions/0013-repository-owned-diagrams-and-redirects.md create mode 100644 src/src/lib/docs/diagrams.ts create mode 100644 src/src/lib/docs/redirect-file.ts create mode 100644 src/tests/unit/docs-diagrams.test.ts create mode 100644 src/tests/unit/docs-redirect-file.test.ts diff --git a/.agents/skills/docs-aggregation-rules/SKILL.md b/.agents/skills/docs-aggregation-rules/SKILL.md index be1ddc6..b5e646c 100644 --- a/.agents/skills/docs-aggregation-rules/SKILL.md +++ b/.agents/skills/docs-aggregation-rules/SKILL.md @@ -175,13 +175,50 @@ so each page shows its title once. 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`). -- A ```` ```likec4 ```` fence in a mirrored page stays **source**, not a diagram: - the whole `docs` collection is relaxed through astroc4's `validation: - { collections: ['docs'] }` (`astro.config.ts`). Such a page was written for - another renderer, so its fence is often a partial snippet; astroc4 logs a - warning and leaves it as a highlighted code block instead of failing the build. - Pages the site authors (`src/pages/**`) are not in the collection, so they keep - strict validation. +- A ```` ```likec4 ```` fence in a mirrored page is rendered when it compiles, and + stays **source** when it does not: the whole `docs` collection is relaxed through + astroc4's `validation: { collections: ['docs'] }` (`astro.config.ts`). Such a page + was written for another renderer, so its fence is often a partial snippet; astroc4 + logs a warning and leaves it as a highlighted code block instead of failing the + build. Pages the site authors (`src/pages/**`) are not in the collection, so they + keep strict validation. + +## Diagram pairs (ADR 0013) + +A product page pairs a GitHub rendering with a LikeC4 view: + +````markdown + +```mermaid +flowchart LR + resume --> next +``` +[LikeC4 model](./diagrams/the-loop.c4) + +```` + +`buildPage` runs `applyDiagramBlocks` (`src/lib/docs/diagrams.ts`) on the raw source before anything +else: the `.c4` file is read from the repository at the channel's ref (resolved relative to the page, +or `/`-rooted at the repository root), and the whole block becomes a ```` ```likec4 ```` fence holding +the model, with `view`, `project`, `title`, `description`, `height`, `collapsible` and `defaultOpen` +passed through. A missing model, an undeclared view or a malformed marker keeps the Mermaid and logs +`docs : line : …`. Markers inside code fences are ignored. + +## Repository redirects (`_redirects.yml`, ADR 0013) + +`/_redirects.yml` (the wiki root for `source: wiki`) holds `previous: current` pairs, either +under `redirects:` or as a bare map; each side is a slug or a Markdown path, reduced with `docSlug`. +It is read per channel and stored as `channels[].redirects` in `src/.cache/docs/index.json`; +`docsPageRedirects(projects, manifest)` writes each channel's pairs from that channel's base, after the +catalogue's `docs.redirects` (which wins on a conflict). An invalid file is a `DocsValidationError`; a +destination that is not a page, or a source that still is, is an advisory line in the sync log. + +## Consumer skills + +`skills/` at the repository root is what product repositories install +(`npx skills add purview-dev/purview-dev`): `purview-site-integration`, `purview-site-docs` and +`purview-site-assessment` (with `scripts/assess.mjs`). They restate these rules for authors, so a change +to slugs, diagram pairs or redirects here changes them in the same pull request. ## Verifying diff --git a/.agents/skills/project-manifest-reference/SKILL.md b/.agents/skills/project-manifest-reference/SKILL.md index 32413c6..d02ca06 100644 --- a/.agents/skills/project-manifest-reference/SKILL.md +++ b/.agents/skills/project-manifest-reference/SKILL.md @@ -64,7 +64,7 @@ Adding a value to any of these lists is a schema change and requires an ADR. `ex | `source` | Fields | Behaviour | | --- | --- | --- | -| `github-path` | `path` (required), plus `rootPage`, `readmeAsIndex`, `order`, `exclude`, `channels` | Walks `/**/*.md` at each channel's ref (default branch when no `channels`) | +| `github-path` | `path` (required), plus `rootPage`, `readmeAsIndex`, `order`, `exclude`, `channels`, `redirects` | Walks `/**/*.md` at each channel's ref (default branch when no `channels`) | | `wiki` | `rootPage`, `readmeAsIndex`, `order`, `exclude` | Shallow-clones `.wiki.git`; `_Sidebar.md` orders pages, `_Footer.md` is skipped. Single channel only | | `readme` | none | Publishes `README.md` alone as `/docs//`. Single channel only | @@ -80,6 +80,9 @@ Adding a value to any of these lists is a schema change and requires an ADR. `ex - `readmeAsIndex` uses the repository `README.md` as the landing page and cannot be combined with `rootPage`; it aliases `readme` to `index`. - `order` is an explicit list of page slugs, applied before the `_Sidebar.md` order. +- `redirects` maps moved slugs (`previous: current`) to keep old URLs alive on every channel that + publishes the tree. Prefer the repository's own `_redirects.yml` in its docs root (ADR 0013), which + lands with the move and is read per channel; the catalogue's pairs win when both name a slug. - If `docs` is set but the aggregation yields no pages, `just data-sync` fails loudly. ## `docs.channels` — live and prerelease documentation (ADR 0009) diff --git a/AGENTS.md b/AGENTS.md index ffe0968..ed81957 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,13 @@ prints advisory observations (which never fail the build) when a project's decla old→new slug in `docs.redirects`, which the build turns into redirects on **every** channel that publishes the tree — the default channel at `/docs//` and the prerelease one at `/docs//prerelease/`, each pair written only once its destination exists; a redirect never - shadows a real page. + shadows a real page. A repository may instead record its own moves in `_redirects.yml` at its + docs root (ADR 0013); the sync reads it per channel and the catalogue's `docs.redirects` wins on + a conflict. An unreadable `_redirects.yml` fails the sync — fix it upstream, never ignore it. + - Diagrams in product docs are **pairs** (ADR 0013): a ```` ```mermaid ```` fence for GitHub inside + `` … ``. The sync replaces the + block with a ```` ```likec4 ```` fence built from the repository's `.c4` file; a missing model or + view keeps the Mermaid and is logged, never a failure. - Agent Skills a project publishes are declared in `skills:` (ADR 0010) at `skills//SKILL.md`; the sync mirrors the whole directory to `/skills///` and each project page renders the same install block diff --git a/docs/decisions/0013-repository-owned-diagrams-and-redirects.md b/docs/decisions/0013-repository-owned-diagrams-and-redirects.md new file mode 100644 index 0000000..b8e40a8 --- /dev/null +++ b/docs/decisions/0013-repository-owned-diagrams-and-redirects.md @@ -0,0 +1,66 @@ +# 0013 — Repository-owned diagram pairs, redirects and integration skills + +- **Status:** Accepted +- **Date:** 2026-11-14 +- **Supersedes:** none + +## Context + +Three gaps showed up as more repositories reorganised their documentation for the portal: + +1. **Diagrams had no good form.** GitHub renders Mermaid but shows LikeC4 as source; the portal renders + LikeC4 (through astroc4) and Mermaid. Repositories fell back to ASCII art (`pm-skills`' "The loop", + the engineering playbook's architecture sketches) or to Mermaid source inside a ```` ```text ```` + fence, which neither side draws. A ```` ```likec4 ```` fence in a mirrored page renders here but is + unreadable on GitHub. +2. **A moved page needed a pull request on this repository.** `docs.redirects` lives in the catalogue, + so the commit that moves a page and the record that keeps its old URL alive land in two + repositories, usually at different times — and in practice the second one is forgotten. The + playbook and build-sdk moved most of their pages into topic folders with redirects for only one. +3. **The rules were only written down here.** The `.agents/` skills describe the portal to agents + working *in this repository*. An agent in a product repository had nothing to install that told it + how to lay out documentation for the portal, and no way to check the result. + +## Decision + +1. **Diagram pairs.** A product page marks a Mermaid fence as the GitHub rendering of a LikeC4 view + with a pair of HTML comments: + + ```markdown + + …a ```mermaid fence, and optionally a link to the model… + + ``` + + GitHub hides the comments. The docs sync (`src/lib/docs/diagrams.ts`) replaces the whole block with + a ```` ```likec4 ```` fence whose body is the `.c4` file, read from the repository at the channel's + ref, so the portal shows only the LikeC4 view. The model is one self-contained file per diagram, + because astroc4 compiles a fence on its own. A block whose model is missing or lacks the view keeps + its Mermaid and the sync logs why: an upstream mistake costs fidelity, never the page or the build. + Markers inside code fences are ignored. +2. **`_redirects.yml` in the docs root.** A repository records its own moves as `previous: current` + pairs (slugs, or Markdown paths from the docs root). The sync reads it at each channel's ref and + stores the pairs on that channel in the docs cache; the build writes them from that channel's own + base (`/docs//` or `/docs//prerelease/`), under the same guards as `docs.redirects`: a + redirect is written only when its destination is published and never shadows a real page. The + catalogue's `docs.redirects` stays and wins on a conflict. An unreadable file fails the sync + (`DocsValidationError`) — silently dropping it would 404 every URL it was written to keep; a pair + that points nowhere is an advisory. +3. **Consumer skills, published from this repository.** `skills/` at the repository root holds skills + for product repositories, installed with `npx skills add purview-dev/purview-dev`: + `purview-site-integration` (what the portal reads and the catalogue contract), `purview-site-docs` + (hierarchical layout, diagram pairs, redirects) and `purview-site-assessment` (a dependency-free + Node script plus a procedure that reports departures and proposes fixes). The `.agents/` skills + remain the internal ones for working on the site. + +## Consequences + +- Documentation guidance that told authors to put Mermaid in a ```` ```text ```` block (the + engineering playbook's *Documentation Convention*) is superseded: both renderers draw Mermaid, and + the pair adds LikeC4 for the portal. That page is updated upstream, in its own repository. +- The assessment script mirrors the sync's slug, diagram and redirect rules. When those rules change in + `src/lib/docs/`, the script and `purview-site-docs` change in the same pull request. +- The docs cache gains an optional `redirects` per channel. No schema bump: an older cache simply has + none until the next `just data-sync`. +- Product repositories can move pages without touching this repository, and the existing catalogue + `docs.redirects` entries can migrate into their repositories over time. diff --git a/src/config/docs-redirects-integration.ts b/src/config/docs-redirects-integration.ts index d6f2a71..c4ce6d0 100644 --- a/src/config/docs-redirects-integration.ts +++ b/src/config/docs-redirects-integration.ts @@ -16,7 +16,8 @@ import { loadProjects } from '../src/lib/manifest/load'; * * A project's `prerelease` channel is withheld automatically once a stable * release catches up with it, a project may declare the slugs a reorganisation - * moved (`docs.redirects`), and `/skills/` moved to the project pages, so none of + * moved (`docs.redirects`, or its own `_redirects.yml`, ADR 0013), and `/skills/` + * moved to the project pages, so none of * those 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. @@ -34,8 +35,9 @@ export default function docsRedirectsIntegration(): AstroIntegration { 'astro:build:done': ({ dir, logger }) => { const dist = fileURLToPath(dir); const projects = loadProjects(); - const retired = writeRetiredChannelRedirects(dist, projects, readDocsManifest()); - const moved = writeDocsPageRedirects(dist, projects); + const manifest = readDocsManifest(); + const retired = writeRetiredChannelRedirects(dist, projects, manifest); + const moved = writeDocsPageRedirects(dist, projects, manifest); const relocated = writeRetiredPageRedirects(dist); if (retired.length + moved.length + relocated.length > 0) { logger.info( diff --git a/src/src/lib/docs/aggregate.ts b/src/src/lib/docs/aggregate.ts index 1f73911..8d1ef25 100644 --- a/src/src/lib/docs/aggregate.ts +++ b/src/src/lib/docs/aggregate.ts @@ -14,6 +14,7 @@ import { metaDescription } from '../seo'; import { OWNER } from '../site'; import { convertGithubAlerts } from './alerts'; import { channelLayout, releaseDateForRef, resolveDocChannels } from './channels'; +import { applyDiagramBlocks, diagramModelPaths } from './diagrams'; import { extractDescription, extractTitle, @@ -28,6 +29,7 @@ import { resolveDoclinks, } from './links'; import { stripMkdocsMarkup } from './mkdocs'; +import { parseRedirectFile, REDIRECTS_FILE } from './redirect-file'; export const DOCS_OUTPUT_DIR = resolve('src/content/docs'); export const DOCS_CACHE_DIR = resolve('.cache/docs'); @@ -71,6 +73,8 @@ export interface AggregatedChannel { label: string; default: boolean; pages: AggregatedPage[]; + /** Moves the repository recorded in its docs root `_redirects.yml` at this channel's ref (ADR 0013). */ + redirects?: Record; } export interface AggregatedProjectDocs { @@ -94,6 +98,8 @@ export interface DocsManifestChannel { label: string; default: boolean; pages: DocsManifestPage[]; + /** `previous: current` slugs from the repository's `_redirects.yml`; absent when it has none. */ + redirects?: Record; } export interface DocsManifestProject { @@ -231,6 +237,15 @@ export async function fetchRawFile( return { path, content, lastModified }; } +/** A repository file's text at a ref, or null when it cannot be read. No commit date is looked up. */ +async function fetchRawText(repository: string, ref: string, path: string): Promise { + const response = await fetch(`https://raw.githubusercontent.com/${repository}/${ref}/${path}`); + return response.ok ? response.text() : null; +} + +/** Reads a repository file for the page being built (a diagram's `.c4` model, ADR 0013). */ +type SourceFileReader = (path: string) => Promise; + export async function listRepositoryFiles(repository: string, branch: string): Promise { const tree = (await githubJson(`/repos/${repository}/git/trees/${branch}?recursive=1`)) as { tree?: { path?: string }[]; @@ -240,7 +255,7 @@ export async function listRepositoryFiles(repository: string, branch: string): P async function cloneWiki( repository: string, -): Promise<{ files: Map; snapshot: string }> { +): Promise<{ files: Map; assets: Map; snapshot: string }> { const target = resolve(tmpdir(), `purview-wiki-${repository.replace('/', '-')}`); rmSync(target, { recursive: true, force: true }); const process = Bun.spawnSync([ @@ -255,16 +270,29 @@ async function cloneWiki( throw new Error(`Failed to clone wiki for ${repository}: ${process.stderr.toString()}`); } const files = new Map(); - for (const entry of readdirSync(target, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.md')) { + const assets = new Map(); + for (const entry of readdirSync(target, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) { continue; } - files.set(entry.name, readFileSync(join(target, entry.name), 'utf8')); + const path = join(entry.parentPath, entry.name) + .slice(target.length + 1) + .replace(/\\/g, '/'); + if (path.startsWith('.git/')) { + continue; + } + // Pages are the top-level Markdown files, as a wiki publishes them. Diagram + // models and the redirects file are read for those pages (ADR 0013). + if (!path.includes('/') && path.endsWith('.md')) { + files.set(path, readFileSync(join(target, path), 'utf8')); + } else if (path.endsWith('.c4') || path === REDIRECTS_FILE) { + assets.set(path, readFileSync(join(target, path), 'utf8')); + } } const headDate = Bun.spawnSync(['git', '-C', target, 'log', '-1', '--format=%cs']); const snapshot = headDate.stdout.toString().trim() || new Date().toISOString().slice(0, 10); rmSync(target, { recursive: true, force: true }); - return { files, snapshot }; + return { files, assets, snapshot }; } function parseWikiSidebar(sidebar: string | undefined): string[] { @@ -448,6 +476,7 @@ async function buildPage( order: number, knownSlugs: Set, headingSlugs: Map>, + readSourceFile: SourceFileReader, slugAliases?: Map, ): Promise { // A slug becomes the published URL, so it must survive as an address verbatim. A folder or file name that reduces @@ -485,10 +514,21 @@ async function buildPage( slugAliases, }; + // A diagram pair is Mermaid on GitHub and LikeC4 here (ADR 0013): swap each + // marked block for its model before any other transform reads the page. + const models = new Map(); + for (const path of diagramModelPaths(raw.content, raw.path)) { + models.set(path, await readSourceFile(path)); + } + const diagrams = applyDiagramBlocks(raw.content, raw.path, models); + for (const warning of diagrams.warnings) { + console.warn(` docs ${project.id}: ${warning}`); + } + // Strip mkdocs-only markup (Material attr_list buttons, TechDocs/Jinja // macros, admonitions) before anything else reads the source, so the title, // description, and rendered body all agree. - const cleaned = stripMkdocsMarkup(raw.content); + const cleaned = stripMkdocsMarkup(diagrams.markdown); const converted = convertGithubAlerts(cleaned); const rewritten = rewriteDocMarkdown(converted, linkContext); const content = normalizeDocumentHeadings(resolveDoclinks(rewritten, slug)); @@ -545,13 +585,51 @@ async function buildPage( }; } +/** One channel's pages and the moves its repository recorded for them. */ +interface CollectedChannel { + pages: AggregatedPage[]; + redirects?: Record; +} + +/** + * Read a docs root `_redirects.yml` (ADR 0013). A file that cannot be used fails + * the sync like any other invalid documentation path: a reorganised tree whose + * redirects silently vanished would 404 every bookmark it was meant to keep. + */ +function readRedirectFile( + project: ResolvedProject, + channel: ResolvedDocChannel, + path: string, + source: string | null | undefined, +): Record | undefined { + if (source === null || source === undefined) { + return undefined; + } + const { redirects, errors } = parseRedirectFile(source); + if (errors.length > 0) { + throw new DocsValidationError( + [ + `Invalid redirects file in ${project.id} (${channel.ref})`, + ...errors.map((error) => ` ${path}: ${error}`), + '', + 'Remediation: make the file a map of `previous: current` page slugs (or Markdown paths from the docs root),', + 'for example `getting-started: guide/getting-started`.', + ].join('\n'), + ); + } + return Object.keys(redirects).length > 0 ? redirects : undefined; +} + async function collectGithubPathChannel( project: ResolvedProject, channel: ResolvedDocChannel, layout: ChannelLayout, -): Promise { +): Promise { const ref = channel.ref; const files = await listRepositoryFiles(project.repository, ref); + const tree = new Set(files); + const readSourceFile: SourceFileReader = (path) => + tree.has(path) ? fetchRawText(project.repository, ref, path) : Promise.resolve(null); // The tree is already in hand, so noticing a repository site configuration // costs nothing — and it is reported, never enforced. warnLegacySiteConfig(project.id, files); @@ -650,6 +728,7 @@ async function collectGithubPathChannel( -1, knownSlugs, headingSlugs, + readSourceFile, selectedRoot.slugAliases, ), ); @@ -672,21 +751,27 @@ async function collectGithubPathChannel( order, knownSlugs, headingSlugs, + readSourceFile, selectedRoot.slugAliases, ), ); } pages.sort((a, b) => a.order - b.order); - return pages; + const redirectsPath = `${docRoot}/${REDIRECTS_FILE}`; + const redirects = tree.has(redirectsPath) + ? readRedirectFile(project, channel, redirectsPath, await readSourceFile(redirectsPath)) + : undefined; + return { pages, redirects }; } async function collectWikiChannel( project: ResolvedProject, channel: ResolvedDocChannel, layout: ChannelLayout, -): Promise { - const { files, snapshot } = await cloneWiki(project.repository); +): Promise { + const { files, assets, snapshot } = await cloneWiki(project.repository); + const readSourceFile: SourceFileReader = (path) => Promise.resolve(assets.get(path) ?? null); const sidebarOrder = parseWikiSidebar(files.get('_Sidebar.md')); const candidates: { name: string; content: string }[] = []; @@ -734,6 +819,7 @@ async function collectWikiChannel( -1, knownSlugs, headingSlugs, + readSourceFile, selectedRoot.slugAliases, ), ); @@ -751,46 +837,55 @@ async function collectWikiChannel( order, knownSlugs, headingSlugs, + readSourceFile, selectedRoot.slugAliases, ), ); } pages.sort((a, b) => a.order - b.order); - return pages; + const redirects = readRedirectFile(project, channel, REDIRECTS_FILE, assets.get(REDIRECTS_FILE)); + return { pages, redirects }; } async function collectReadmeChannel( project: ResolvedProject, channel: ResolvedDocChannel, -): Promise { +): Promise { const readme = await fetchRawFile(project.repository, channel.ref, 'README.md'); if (!readme) { - return []; + return { pages: [] }; } - return [ - await buildPage( - project, - channel, - '', - 'index', - readme, - '', - -1, - new Set(['index']), - new Map([['index', extractHeadingSlugs(stripMkdocsMarkup(readme.content))]]), - ), - ]; + // A README is one page, so it has nothing to redirect, but it can still pair a + // diagram with a model elsewhere in the repository. + const readSourceFile: SourceFileReader = (path) => + fetchRawText(project.repository, channel.ref, path); + return { + pages: [ + await buildPage( + project, + channel, + '', + 'index', + readme, + '', + -1, + new Set(['index']), + new Map([['index', extractHeadingSlugs(stripMkdocsMarkup(readme.content))]]), + readSourceFile, + ), + ], + }; } /** Collect one channel's pages for a project, whatever its documentation source. */ async function collectChannelPages( project: ResolvedProject, channel: ResolvedDocChannel, -): Promise { +): Promise { const layout = channelLayout(project, channel); if (!layout) { - return []; + return { pages: [] }; } switch (project.docs?.source) { case 'github-path': @@ -800,8 +895,30 @@ async function collectChannelPages( case 'readme': return collectReadmeChannel(project, channel); default: - return []; + return { pages: [] }; + } +} + +/** + * Advisories for a channel's `_redirects.yml`: a move whose destination is not + * a page here, or whose source is still a page (the build never shadows a real + * page, so that pair is inert until the old page goes). + */ +export function redirectFileWarnings( + redirects: Record | undefined, + pageSlugs: ReadonlySet, +): string[] { + const warnings: string[] = []; + for (const [from, to] of Object.entries(redirects ?? {})) { + if (!pageSlugs.has(to)) { + warnings.push(`${REDIRECTS_FILE}: "${from}" points at "${to}", which is not a page.`); + } else if (pageSlugs.has(from)) { + warnings.push( + `${REDIRECTS_FILE}: "${from}" is still a page, so its redirect is not written.`, + ); + } } + return warnings; } /** @@ -826,14 +943,18 @@ export async function aggregateDocs(projects: ResolvedProject[]): Promise page.slug)); + for (const warning of redirectFileWarnings(redirects, slugs)) { + console.warn(` docs ${project.id} (${channel.id}): ${warning}`); + } + aggregatedChannels.push({ ...channel, pages, ...(redirects ? { redirects } : {}) }); } // A channel id is a URL segment, so a page may not shadow one. @@ -918,6 +1039,8 @@ export function writeDocs( sourcePath: page.sourcePath, hash: page.hash, })), + // Absent when the repository has no `_redirects.yml`; JSON drops an undefined key. + redirects: channel.redirects, })), }); } diff --git a/src/src/lib/docs/diagrams.ts b/src/src/lib/docs/diagrams.ts new file mode 100644 index 0000000..9ab8a40 --- /dev/null +++ b/src/src/lib/docs/diagrams.ts @@ -0,0 +1,222 @@ +import { posix } from 'node:path'; + +/** + * Diagram pairs: one diagram, two renderers (ADR 0013). + * + * A product repository's documentation is read in two places — on GitHub, which + * renders Mermaid but not LikeC4, and on purview.dev, which renders LikeC4 + * through astroc4. A repository marks a Mermaid fence as the GitHub rendering of + * a LikeC4 view by wrapping it in a pair of HTML comments: + * + * ````md + * + * ```mermaid + * flowchart LR + * resume --> next + * ``` + * [LikeC4 model](./diagrams/the-loop.c4) + * + * ```` + * + * GitHub hides the comments and shows everything between them. The docs sync + * replaces the whole block — markers, Mermaid fence and model link — with a + * ```` ```likec4 ```` fence whose body is the `.c4` file, so the site shows only + * the LikeC4 view. A block whose model cannot be read, or whose view the model + * does not declare, keeps its Mermaid content and the sync reports why: the + * reader still gets a diagram, and the build never fails on an upstream file. + */ + +export const DIAGRAM_OPEN = /^ {0,3}\s*$/; +export const DIAGRAM_CLOSE = /^ {0,3}\s*$/; +const FENCE = /^ {0,3}(`{3,}|~{3,})/; + +/** Fence options astroc4 understands; `file` is consumed by the sync instead. */ +const PASSTHROUGH_OPTIONS = [ + 'view', + 'project', + 'title', + 'description', + 'height', + 'collapsible', + 'defaultOpen', +]; + +export interface DiagramBlock { + /** Index of the opening marker line. */ + start: number; + /** Index of the closing marker line. */ + end: number; + options: Record; + /** Lines between the markers: the GitHub rendering (Mermaid, a link to the model). */ + inner: string; +} + +/** Parse `key="value" key='value' key=bare` pairs from a marker. */ +export function parseDiagramOptions(source: string): Record { + const options: Record = {}; + const pattern = /([A-Za-z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source))) { + options[match[1] ?? ''] = match[2] ?? match[3] ?? match[4] ?? ''; + } + return options; +} + +/** + * Every marked block in a page, skipping markers that sit inside a code fence (a + * page documenting the convention shows the markers as an example). + */ +export function findDiagramBlocks(markdown: string): { + blocks: DiagramBlock[]; + problems: string[]; +} { + const lines = markdown.split('\n'); + const blocks: DiagramBlock[] = []; + const problems: string[] = []; + let fence: { char: string; length: number } | null = null; + let open: { start: number; options: Record } | null = null; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index] ?? ''; + const fenceMatch = FENCE.exec(line); + if (fenceMatch) { + const run = fenceMatch[1] ?? ''; + if (!fence) { + fence = { char: run[0] ?? '`', length: run.length }; + continue; + } + if (run[0] === fence.char && run.length >= fence.length && line.trim() === run) { + fence = null; + continue; + } + } + if (fence) { + continue; + } + const opening = DIAGRAM_OPEN.exec(line); + if (opening) { + if (open) { + problems.push( + `line ${open.start + 1}: a purview:likec4 block is not closed before the next one opens.`, + ); + } + open = { start: index, options: parseDiagramOptions(opening[1] ?? '') }; + continue; + } + if (DIAGRAM_CLOSE.test(line)) { + if (!open) { + problems.push(`line ${index + 1}: a closing purview:likec4 marker has no opening marker.`); + continue; + } + blocks.push({ + start: open.start, + end: index, + options: open.options, + inner: lines.slice(open.start + 1, index).join('\n'), + }); + open = null; + } + } + if (open) { + problems.push(`line ${open.start + 1}: a purview:likec4 block is never closed.`); + } + return { blocks, problems }; +} + +/** + * Repository path of a model file named relative to the page that names it, or + * null when it would leave the repository. A leading `/` is the repository root, + * the way GitHub resolves it. + */ +export function resolveDiagramPath(pagePath: string, file: string): string | null { + const joined = file.startsWith('/') + ? posix.normalize(file.slice(1)) + : posix.normalize(posix.join(posix.dirname(pagePath), file)); + if (joined === '.' || joined.startsWith('../') || joined === '..') { + return null; + } + return joined; +} + +/** View ids a LikeC4 source declares (`view theLoop { … }`, `dynamic view theLoop { … }`). */ +export function declaredViews(source: string): Set { + const views = new Set(); + const pattern = /(?:^|[\s{;])(?:dynamic\s+|deployment\s+)?view\s+([A-Za-z_][\w-]*)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source))) { + views.add(match[1] ?? ''); + } + return views; +} + +/** A ```` ```likec4 ```` fence holding `source`, long enough that nothing inside closes it. */ +export function renderLikec4Fence(source: string, options: Record): string { + const longest = Math.max(2, ...[...source.matchAll(/`+/g)].map((run) => run[0].length)); + const ticks = '`'.repeat(longest + 1); + const meta = PASSTHROUGH_OPTIONS.filter((key) => options[key] !== undefined) + .map((key) => `${key}="${(options[key] ?? '').replace(/"/g, "'")}"`) + .join(' '); + return `${ticks}likec4${meta ? ` ${meta}` : ''}\n${source.replace(/\s+$/, '')}\n${ticks}`; +} + +/** Repository paths of the model files a page's diagram blocks name. */ +export function diagramModelPaths(markdown: string, pagePath: string): string[] { + const paths = new Set(); + for (const block of findDiagramBlocks(markdown).blocks) { + const file = block.options.file; + const resolved = file ? resolveDiagramPath(pagePath, file) : null; + if (resolved) { + paths.add(resolved); + } + } + return [...paths]; +} + +/** + * Replace each diagram block with its LikeC4 fence. `models` maps a repository + * path to the file's text, or null when it could not be read. A block that cannot + * be rendered as LikeC4 keeps its GitHub content, and the reason is returned. + */ +export function applyDiagramBlocks( + markdown: string, + pagePath: string, + models: ReadonlyMap, +): { markdown: string; warnings: string[] } { + const { blocks, problems } = findDiagramBlocks(markdown); + const warnings = problems.map((problem) => `${pagePath} ${problem}`); + if (blocks.length === 0) { + return { markdown, warnings }; + } + const lines = markdown.split('\n'); + const output: string[] = []; + let cursor = 0; + for (const block of blocks) { + output.push(...lines.slice(cursor, block.start)); + cursor = block.end + 1; + const where = `${pagePath} line ${block.start + 1}`; + const file = block.options.file; + if (!file) { + warnings.push( + `${where}: the purview:likec4 marker has no file="…"; the Mermaid rendering is kept.`, + ); + output.push(block.inner); + continue; + } + const path = resolveDiagramPath(pagePath, file); + const source = path ? models.get(path) : null; + if (!path || source === null || source === undefined) { + warnings.push(`${where}: ${file} could not be read; the Mermaid rendering is kept.`); + output.push(block.inner); + continue; + } + const view = block.options.view; + if (view && !declaredViews(source).has(view)) { + warnings.push(`${where}: ${file} declares no view "${view}"; the Mermaid rendering is kept.`); + output.push(block.inner); + continue; + } + output.push(renderLikec4Fence(source, block.options)); + } + output.push(...lines.slice(cursor)); + return { markdown: output.join('\n'), warnings }; +} diff --git a/src/src/lib/docs/redirect-file.ts b/src/src/lib/docs/redirect-file.ts new file mode 100644 index 0000000..6d78c23 --- /dev/null +++ b/src/src/lib/docs/redirect-file.ts @@ -0,0 +1,84 @@ +import { parse } from 'yaml'; + +import { docSlug, isUrlSafeDocSlug } from './links'; + +/** + * A repository's own record of the documentation pages it moved (ADR 0013). + * + * The catalogue's `docs.redirects` works, but it puts the knowledge of a move in + * a second repository: whoever reorganises the docs tree has to open a pull + * request here as well. `_redirects.yml` in the docs root lets the move and its + * redirect land in the same commit. It is read at each channel's own ref, so a + * tree reorganised on the default branch forwards the prerelease channel's URLs + * without touching the live ones until the release that carries it is promoted. + * + * ```yaml + * # previous: current — slugs, or paths from the docs root + * redirects: + * getting-started: guide/getting-started + * Custom-Rules.md: schemas/Custom-Rules.md + * ``` + * + * A bare map (without the `redirects:` key) is accepted too. Each side may be a + * slug or a Markdown path from the docs root; both are reduced to the slug the + * page publishes at, exactly as the page itself is. + */ +export const REDIRECTS_FILE = '_redirects.yml'; + +export interface RedirectFileResult { + redirects: Record; + /** Problems that make the file unusable; the caller decides how loudly to fail. */ + errors: string[]; +} + +/** Reduce one side of a pair to the slug it publishes at. */ +function toSlug(value: string): string { + const trimmed = value.trim().replace(/^\/+|\/+$/g, ''); + return docSlug(/\.(md|mdx)$/i.test(trimmed) ? trimmed : `${trimmed}.md`); +} + +export function parseRedirectFile(source: string): RedirectFileResult { + let parsed: unknown; + try { + parsed = parse(source); + } catch (error) { + return { + redirects: {}, + errors: [`not valid YAML: ${(error as Error).message.split('\n')[0]}`], + }; + } + if (parsed === null || parsed === undefined) { + return { redirects: {}, errors: [] }; + } + const root = + typeof parsed === 'object' && !Array.isArray(parsed) && 'redirects' in parsed + ? (parsed as { redirects: unknown }).redirects + : parsed; + if (root === null || root === undefined) { + return { redirects: {}, errors: [] }; + } + if (typeof root !== 'object' || Array.isArray(root)) { + return { redirects: {}, errors: ['must be a map of `previous: current` pairs'] }; + } + + const redirects: Record = {}; + const errors: string[] = []; + for (const [rawFrom, rawTo] of Object.entries(root as Record)) { + if (typeof rawTo !== 'string' || rawTo.trim() === '') { + errors.push(`"${rawFrom}" must map to a page slug or path`); + continue; + } + const from = toSlug(rawFrom); + const to = toSlug(rawTo); + if (!isUrlSafeDocSlug(from) || !isUrlSafeDocSlug(to)) { + errors.push(`"${rawFrom}: ${rawTo}" does not reduce to publishable slugs`); + continue; + } + if (from === to) { + errors.push(`"${rawFrom}" redirects to itself`); + continue; + } + redirects[from] = to; + } + return { redirects, errors }; +} diff --git a/src/src/lib/docs/redirects.ts b/src/src/lib/docs/redirects.ts index 5c086bf..61db234 100644 --- a/src/src/lib/docs/redirects.ts +++ b/src/src/lib/docs/redirects.ts @@ -114,35 +114,66 @@ export function retiredChannelRedirects( * 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). + * + * A repository may also record its own moves in `_redirects.yml` (ADR 0013). + * That file is read at each channel's ref, so its pairs apply to that channel's + * base only — the prerelease tree can move before the live one does. When the + * catalogue and the file both name the same old slug, the catalogue wins: it is + * the reviewed record on this side. */ -export function docsPageRedirects(projects: readonly ResolvedProject[]): DocChannelRedirect[] { +export function docsPageRedirects( + projects: readonly ResolvedProject[], + manifest?: DocsManifest | null, +): DocChannelRedirect[] { const redirects: DocChannelRedirect[] = []; + const seen = new Set(); + const add = (base: string, from: string, to: string) => { + const redirect = { + from: pagePath(base, from), + to: pagePath(base, to), + title: 'This documentation page moved…', + message: 'The page now lives at its new address.', + }; + if (!seen.has(redirect.from)) { + seen.add(redirect.from); + redirects.push(redirect); + } + }; for (const project of projects) { - if (project.docs?.source !== 'github-path' || project.status === 'archived') { + if (!project.docs || project.docs.source === 'readme' || project.status === 'archived') { 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`); + if (project.docs.source === 'github-path') { + 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)) { + for (const base of bases) { + add(base, from, to); + } + } } - for (const [from, to] of Object.entries(moved)) { - 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.', - }); + const entry = manifest?.projects.find((candidate) => candidate.projectId === project.id); + for (const channel of entry?.channels ?? []) { + const base = channel.default ? `/docs/${project.id}` : `/docs/${project.id}/${channel.id}`; + for (const [from, to] of Object.entries(channel.redirects ?? {})) { + add(base, from, to); } } } return redirects; } +/** Site path of a page slug under a docs base; `index` is the base itself. */ +function pagePath(base: string, slug: string): string { + return slug === 'index' ? `${base}/` : `${base}/${slug.replace(/\/index$/, '')}/`; +} + /** * Write the moved-page redirects into a built site. Returns the paths written, relative to `dist`. * @@ -157,9 +188,10 @@ export function docsPageRedirects(projects: readonly ResolvedProject[]): DocChan export function writeDocsPageRedirects( dist: string, projects: readonly ResolvedProject[], + manifest?: DocsManifest | null, ): string[] { const written: string[] = []; - for (const redirect of docsPageRedirects(projects)) { + for (const redirect of docsPageRedirects(projects, manifest)) { if (!isPublished(dist, redirect.to)) { continue; } diff --git a/src/tests/unit/docs-diagrams.test.ts b/src/tests/unit/docs-diagrams.test.ts new file mode 100644 index 0000000..2530e7f --- /dev/null +++ b/src/tests/unit/docs-diagrams.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applyDiagramBlocks, + declaredViews, + diagramModelPaths, + findDiagramBlocks, + renderLikec4Fence, + resolveDiagramPath, +} from '../../src/lib/docs/diagrams'; + +const MODEL = `specification { element step } +model { + resume = step 'pm-resume' + next = step 'pm-next' + resume -> next +} +views { + view theLoop { + include * + } +}`; + +const PAGE = [ + '# PM Skills', + '', + '## The loop', + '', + '', + '```mermaid', + 'flowchart LR', + ' resume --> next', + '```', + '', + '[LikeC4 model](./docs/diagrams/the-loop.c4)', + '', + '', + 'After the diagram.', +].join('\n'); + +describe('diagram pairs', () => { + test('finds a marked block and its options', () => { + const { blocks, problems } = findDiagramBlocks(PAGE); + expect(problems).toEqual([]); + expect(blocks).toHaveLength(1); + expect(blocks[0]?.options).toEqual({ + file: './docs/diagrams/the-loop.c4', + view: 'theLoop', + title: 'The loop', + }); + expect(blocks[0]?.inner).toContain('```mermaid'); + }); + + test('ignores markers shown inside a code fence', () => { + const documented = ['````md', PAGE, '````'].join('\n'); + expect(findDiagramBlocks(documented).blocks).toEqual([]); + }); + + test('reports a block that is never closed', () => { + const { blocks, problems } = findDiagramBlocks('\ntext'); + expect(blocks).toEqual([]); + expect(problems[0]).toContain('never closed'); + }); + + test('resolves a model path against the page, and refuses to leave the repository', () => { + expect(resolveDiagramPath('README.md', './docs/diagrams/the-loop.c4')).toBe( + 'docs/diagrams/the-loop.c4', + ); + expect(resolveDiagramPath('docs/wiki/guide/Loop.md', '../diagrams/loop.c4')).toBe( + 'docs/wiki/diagrams/loop.c4', + ); + expect(resolveDiagramPath('docs/wiki/Loop.md', '/models/loop.c4')).toBe('models/loop.c4'); + expect(resolveDiagramPath('README.md', '../outside.c4')).toBeNull(); + }); + + test('lists the model files a page needs', () => { + expect(diagramModelPaths(PAGE, 'README.md')).toEqual(['docs/diagrams/the-loop.c4']); + }); + + test('reads the declared views, dynamic ones included', () => { + expect([...declaredViews(`${MODEL}\nviews { dynamic view flow { } }`)]).toEqual([ + 'theLoop', + 'flow', + ]); + }); + + test('replaces the whole block with a likec4 fence of the model', () => { + const { markdown, warnings } = applyDiagramBlocks( + PAGE, + 'README.md', + new Map([['docs/diagrams/the-loop.c4', MODEL]]), + ); + expect(warnings).toEqual([]); + expect(markdown).toContain('```likec4 view="theLoop" title="The loop"\nspecification'); + expect(markdown).not.toContain('mermaid'); + expect(markdown).not.toContain('purview:likec4'); + expect(markdown).not.toContain('[LikeC4 model]'); + expect(markdown).toContain('After the diagram.'); + }); + + test('keeps the Mermaid rendering when the model cannot be read', () => { + const { markdown, warnings } = applyDiagramBlocks( + PAGE, + 'README.md', + new Map([['docs/diagrams/the-loop.c4', null]]), + ); + expect(markdown).toContain('```mermaid'); + expect(markdown).not.toContain('purview:likec4'); + expect(warnings[0]).toContain('could not be read'); + }); + + test('keeps the Mermaid rendering when the model lacks the view', () => { + const { markdown, warnings } = applyDiagramBlocks( + PAGE.replace('view="theLoop"', 'view="missing"'), + 'README.md', + new Map([['docs/diagrams/the-loop.c4', MODEL]]), + ); + expect(markdown).toContain('```mermaid'); + expect(warnings[0]).toContain('no view "missing"'); + }); + + test('a fence is longer than any backtick run in the model', () => { + expect(renderLikec4Fence('a ``` b', {}).startsWith('````likec4\n')).toBe(true); + }); +}); diff --git a/src/tests/unit/docs-redirect-file.test.ts b/src/tests/unit/docs-redirect-file.test.ts new file mode 100644 index 0000000..1873b0b --- /dev/null +++ b/src/tests/unit/docs-redirect-file.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'bun:test'; + +import { redirectFileWarnings } from '../../src/lib/docs/aggregate'; +import { parseRedirectFile } from '../../src/lib/docs/redirect-file'; +import { docsPageRedirects } from '../../src/lib/docs/redirects'; +import { loadProjects } from '../../src/lib/manifest/load'; + +describe('repository redirects file', () => { + test('reads `redirects:` pairs as slugs', () => { + expect( + parseRedirectFile( + [ + '# previous: current', + 'redirects:', + ' getting-started: guide/getting-started', + ' Custom-Rules.md: schemas/Custom-Rules.md', + ].join('\n'), + ), + ).toEqual({ + redirects: { + 'getting-started': 'guide/getting-started', + 'custom-rules': 'schemas/custom-rules', + }, + errors: [], + }); + }); + + test('accepts a bare map and an empty file', () => { + expect(parseRedirectFile('old: new').redirects).toEqual({ old: 'new' }); + expect(parseRedirectFile('# nothing moved yet\n')).toEqual({ redirects: {}, errors: [] }); + }); + + test('rejects pairs that cannot be published', () => { + expect(parseRedirectFile('redirects:\n - a\n - b').errors).toHaveLength(1); + expect(parseRedirectFile('old:\n').errors[0]).toContain('must map'); + expect(parseRedirectFile('same: same.md').errors[0]).toContain('itself'); + expect(parseRedirectFile('old: [unclosed').errors[0]).toContain('not valid YAML'); + }); + + test('warns about a destination that is not a page and a source that still is', () => { + const pages = new Set(['index', 'guide/getting-started', 'still-here']); + expect( + redirectFileWarnings( + { 'getting-started': 'guide/getting-started', gone: 'missing', 'still-here': 'index' }, + pages, + ), + ).toEqual([ + '_redirects.yml: "gone" points at "missing", which is not a page.', + '_redirects.yml: "still-here" is still a page, so its redirect is not written.', + ]); + }); +}); + +describe('redirects from a repository file', () => { + const project = loadProjects().find((candidate) => candidate.id === 'unions'); + if (!project) { + throw new Error('the unions project is expected in the catalogue'); + } + + test('forwards each channel from its own base, and the landing page to the base itself', () => { + const redirects = docsPageRedirects([project], { + schema: 3, + syncedAt: '', + releaseRetrievedAt: '', + projects: [ + { + projectId: 'unions', + channels: [ + { + id: 'live', + ref: 'main', + refKind: 'branch', + version: null, + label: 'main', + default: true, + pages: [], + redirects: { 'getting-started': 'guide/getting-started', home: 'index' }, + }, + { + id: 'prerelease', + ref: 'main', + refKind: 'branch', + version: null, + label: 'main', + default: false, + pages: [], + redirects: { performance: 'guide/performance' }, + }, + ], + }, + ], + }); + expect(redirects.map(({ from, to }) => ({ from, to }))).toEqual([ + { from: '/docs/unions/getting-started/', to: '/docs/unions/guide/getting-started/' }, + { from: '/docs/unions/home/', to: '/docs/unions/' }, + { + from: '/docs/unions/prerelease/performance/', + to: '/docs/unions/prerelease/guide/performance/', + }, + ]); + }); +}); From 214a485c099a346dcfb6cd2d516bf3c3b3cee11e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 14:01:44 +0000 Subject: [PATCH 5/7] feat(skills): publish integration skills for product repositories --- .../skills/audit-catalogue-projects/SKILL.md | 8 + .github/copilot-instructions.md | 6 + AGENTS.md | 10 +- README.md | 16 + skills/purview-site-assessment/SKILL.md | 109 +++ .../references/rules.md | 79 +++ .../scripts/assess.mjs | 669 ++++++++++++++++++ skills/purview-site-docs/SKILL.md | 117 +++ .../purview-site-docs/references/diagrams.md | 174 +++++ skills/purview-site-docs/references/layout.md | 92 +++ .../purview-site-docs/references/redirects.md | 49 ++ skills/purview-site-integration/SKILL.md | 89 +++ .../references/catalogue-contract.md | 93 +++ 13 files changed, 1510 insertions(+), 1 deletion(-) create mode 100644 skills/purview-site-assessment/SKILL.md create mode 100644 skills/purview-site-assessment/references/rules.md create mode 100644 skills/purview-site-assessment/scripts/assess.mjs create mode 100644 skills/purview-site-docs/SKILL.md create mode 100644 skills/purview-site-docs/references/diagrams.md create mode 100644 skills/purview-site-docs/references/layout.md create mode 100644 skills/purview-site-docs/references/redirects.md create mode 100644 skills/purview-site-integration/SKILL.md create mode 100644 skills/purview-site-integration/references/catalogue-contract.md diff --git a/.agents/skills/audit-catalogue-projects/SKILL.md b/.agents/skills/audit-catalogue-projects/SKILL.md index 4345105..90328d7 100644 --- a/.agents/skills/audit-catalogue-projects/SKILL.md +++ b/.agents/skills/audit-catalogue-projects/SKILL.md @@ -57,6 +57,10 @@ Related: `project-manifest-reference` (fields and vocabularies), `docs-aggregati - The aggregated page count is non-zero and the landing page is the page a reader should land on. - Archived projects that still declare `docs` are intentional (they are excluded from the portal). - Documentation is not duplicated here: this repository only holds the mirror. +- Pages moved in the repository's history have a redirect — in its `_redirects.yml` or the entry's + `docs.redirects` (ADR 0013). Diagrams are LikeC4/Mermaid pairs, not ASCII art or Mermaid in a + `text` fence. The consumer assessment script reports both against a clone (below); file the + repository-side fixes as `upstream` items, since they belong in the product repository. ### Stability @@ -112,6 +116,10 @@ gh api "repos/purview-dev//git/trees/main?recursive=1" --jq '[.tree[].path gh api "repos/purview-dev//releases?per_page=10" --jq '.[] | {tag_name, prerelease}' curl.exe -s "https://api.nuget.org/v3-flatcontainer//index.json" +# Repository-side integration (docs layout, diagrams, redirects, skills) against a fresh clone +git clone --depth 300 https://github.com/purview-dev/.git / +node skills/purview-site-assessment/scripts/assess.mjs / --catalogue src/src/data/projects/.yml + # Aggregated result node -e "const m=require('./src/.cache/docs/index.json');for(const p of m.projects)console.log(p.projectId,p.pages.length)" ``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cc6e6bd..d8b9124 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,11 @@ package is `src/`. npm for a package with `registry: npm`). Apply the machine-applicable ones with `just fix-projects` (dry run; pass `--write` to edit). - Keep documentation where it lives (the product repository). This site aggregates it at build time. + Product repositories own their diagram pairs (`purview:likec4` blocks) and their page moves + (`_redirects.yml` in the docs root) — ADR 0013. +- `skills/` at the root is for **product repositories** (`npx skills add purview-dev/purview-dev`); + `.agents/` is for work on this site. When the docs rules in `src/src/lib/docs/` change, update + `skills/purview-site-docs` and `skills/purview-site-assessment/scripts/assess.mjs` with them. - Keep the package version hydration intact (ADR 0008, ADR 0012): the build-time version renders first, the browser only ever raises it, and the result is stored in `localStorage` for six hours. Do not move any other data to the client. @@ -51,5 +56,6 @@ package is `src/`. `.agents/skills/add-catalogue-project/SKILL.md` (the procedure). - Auditing/fixing the catalogue → `.agents/agents/catalogue-auditor.agent.md` and `.agents/skills/audit-catalogue-projects/SKILL.md`. +- Assessing a product repository's integration → `skills/purview-site-assessment/SKILL.md`. - Any other change → `.agents/` holds the manifest reference, docs-aggregation rules, use-case authoring rules, repo-metadata guidance, and the validation loop. diff --git a/AGENTS.md b/AGENTS.md index ed81957..50b160a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,13 +164,21 @@ Do not assume this file contains everything; consult `.agents/` while planning. - `.agents/skills/site-validation-loop/SKILL.md` — the checks, and how to reproduce failures. - `.agents/prompts/add-project.prompt.md`, `.agents/prompts/audit-projects.prompt.md`. +`skills/` (repository root) is different: it holds the **consumer** skills that product repositories +install with `npx skills add purview-dev/purview-dev` — `purview-site-integration`, +`purview-site-docs` and `purview-site-assessment` (ADR 0013). They restate the docs-aggregation rules +for authors; change them in the same pull request as `src/src/lib/docs/`. When onboarding or auditing a +project, run `node skills/purview-site-assessment/scripts/assess.mjs --catalogue +src/src/data/projects/.yml` against a clone of its repository. + ## Key entry points | Concern | Location | | --- | --- | | Catalogue manifest | `src/src/data/projects/.yml` (one per project) + `src/src/data/external-projects.yml` | | Manifest schema / loader | `src/src/lib/manifest/{schema,load}.ts` | -| Docs aggregation | `src/src/lib/docs/aggregate.ts` (+ `frontmatter`, `links`, `sidebar`, `staleness`) | +| Docs aggregation | `src/src/lib/docs/aggregate.ts` (+ `frontmatter`, `links`, `sidebar`, `staleness`, `diagrams`, `redirect-file`) | +| Consumer skills for product repositories | `skills/purview-site-{integration,docs,assessment}/` | | Release data | `src/src/lib/releases/*`, `src/scripts/fetch-releases.ts` | | Mirrored project assets | `src/scripts/sync-assets.ts` (`assets:` in the manifest) | | Pages | `src/src/pages/{index,about,use-cases,docs,projects,releases}/` | diff --git a/README.md b/README.md index 0b70b9d..5ba6195 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ purview-dev/ # workspace root ├── justfile # task runner (runs in the src/ package) ├── AGENTS.md # repository context and agent rules (ADR 0003) ├── .agents/ # agents, skills, and prompts for catalogue/docs work +├── skills/ # skills product repositories install (npx skills add purview-dev/purview-dev) ├── docs/decisions/ # architectural decision records └── src/ # the Astro site package (@purview-dev/website) ├── assets/branding/ # approved branding sources (never modified) @@ -397,6 +398,21 @@ skills: `just check-projects` fails when a declared skill has no mirror, and `just check-generated` fails when one is missing from the build. +### Skills for product repositories + +This repository also publishes skills, from `skills/` at its root, for agents +working in the product repositories (ADR 0013): + +```shell +npx skills add purview-dev/purview-dev +``` + +| Skill | Use it to | +| --- | --- | +| `purview-site-integration` | make a repository readable by the site, and prepare its catalogue entry | +| `purview-site-docs` | lay out documentation hierarchically, pair LikeC4 and Mermaid diagrams, record moves in `_redirects.yml` | +| `purview-site-assessment` | run `scripts/assess.mjs` over a checkout and get a prioritised report with fixes | + ## Release data Release data is fetched at build time — never from the visitor's browser. diff --git a/skills/purview-site-assessment/SKILL.md b/skills/purview-site-assessment/SKILL.md new file mode 100644 index 0000000..64afe45 --- /dev/null +++ b/skills/purview-site-assessment/SKILL.md @@ -0,0 +1,109 @@ +--- +name: purview-site-assessment +description: Assess how well a Purview repository integrates with purview.dev and recommend fixes - runs a dependency-free script over the checkout (docs layout and hierarchy, root page, slugs and collisions, titles, broken links and images, ASCII-art and unlabelled Mermaid diagrams, purview:likec4 diagram pairs and their .c4 models, _redirects.yml and page moves in git history that left no redirect, agent skills front matter), cross-checks the site's catalogue entry and the live GitHub metadata, and produces a prioritised report with a concrete fix for each finding. Use before a repository joins purview.dev, after reorganising docs, when the site shows something wrong for the project, or when asked to audit or "assess" site integration. +license: MIT +metadata: + site: https://purview.dev + repository: purview-dev/purview-dev +--- + +# Assessing a repository's purview.dev integration + +The rules being checked are in `purview-site-docs` (how documentation is written) and +`purview-site-integration` (everything else the site reads). This skill finds where a repository +departs from them, ranks the findings, and proposes fixes. It **reports first and edits only when +asked**; and it never edits `purview-dev/purview-dev` — catalogue changes are proposed as a snippet for +the site maintainers. + +## 1. Fetch the catalogue entry (when the project is on the site) + +The entry carries contracts the repository must keep: the docs path, the root page, every use case's +`docsPage` deep link, and any redirects recorded on the site side. + +```shell +curl -sfL https://raw.githubusercontent.com/purview-dev/purview-dev/main/src/src/data/projects/.yml -o .purview-entry.yml +``` + +`` is normally the repository name (`unions`, `build-sdk`, `pm-skills`). A 404 means the project is +not on the site yet — assess without it and include the entry proposal from +`purview-site-integration` (references/catalogue-contract.md) in the report. Delete +`.purview-entry.yml` when done; never commit it. + +## 2. Run the script + +```shell +node /scripts/assess.mjs . --catalogue .purview-entry.yml +``` + +`` is where the skill was installed (for example `.agents/skills/purview-site-assessment`). +Options: `--docs ` to name the docs folder (default `docs/wiki`, then `docs`), `--json` for +machine-readable output. It reads files and `git log` only, needs Node 18+, and exits 1 when an +error-level finding exists, so it can also run in CI. + +Every rule id it prints is explained, with how to fix it, in [references/rules.md](references/rules.md). + +## 3. Check what the script cannot see + +```shell +gh api repos/purview-dev/ --jq '{description, topics, archived, default_branch, has_discussions}' +gh api "repos/purview-dev//releases?per_page=5" --jq '.[] | {tag_name, prerelease, published_at}' +``` + +| Check | Finding when it fails | +| -- | -- | +| 6–15 topics, lower-case and hyphenated | **META-001** warning — the card shows no or weak tags | +| a description that agrees with the entry's `shortDescription` | **META-002** info | +| `default_branch` is `main` (or the entry says otherwise) | **META-003** error — the docs and edit links follow it | +| at least one release, tagged `vX.Y.Z` or `vX.Y.Z-prerelease.N`, matching the package version | **META-004** warning — no version is shown | +| entry `status` agrees: stable registry version → `stable`; prerelease only → `preview`; archived repository → `archived` | **META-005** warning | +| the repository is not archived while the entry says `stable`/`preview` | **META-006** error | + +Then read what a script cannot judge: + +- **Hierarchy.** Do the topic folders match how a reader looks for things? Are pages in the folder + for their topic? A DOCS-003 finding needs a proposed tree, not just "add folders". +- **Diagrams.** For each DIAG-001 (ASCII art) and DIAG-008 (Mermaid in a text fence), decide whether it + is an architecture or flow diagram (→ LikeC4 model + Mermaid pair) or a small sketch (→ a labelled + ```` ```mermaid ```` fence is enough). Draft the model for the most important one. +- **README vs docs.** Does the README point into the docs, or duplicate them? +- **Skills.** Does each `description` say what the skill does *and when to use it*? + +## 4. Report + +Lead with a one-paragraph verdict, then the findings grouped by priority, each with the fix written +out — the redirect line, the renamed path, the converted fence — not just the rule: + +```markdown +# purview.dev integration: + + + +## Blockers (break the site's sync or build) +| Rule | Where | Problem | Fix | + +## Should fix (readers see something wrong) +| Rule | Where | Problem | Fix | + +## Improvements +| Rule | Where | Problem | Fix | + +## Proposed changes +- `docs/wiki/_redirects.yml` — the full file. +- The proposed docs tree, when DOCS-003 fired. +- One converted diagram pair (model + Mermaid), when DIAG-001/008 fired. +- The catalogue entry snippet for the site maintainers, when the entry is missing or drifts. +``` + +Blockers are every `error`; "should fix" is every `warning` plus META errors; improvements are +`info`. Collapse repeated findings into one row with a count and the list of places. + +## 5. Fixing (only when asked) + +Work in this order — each step changes what the next one sees: + +1. Blockers: unclosed fences, broken links and images, slug collisions, missing deep-link pages. +2. Redirects for moves already made (RED-004), before any further move. +3. Reorganise a flat tree into topic folders (`git mv`), adding each move to `_redirects.yml` and fixing + inbound links in the same commit. Leave catalogue deep-linked pages at the root. +4. Convert diagrams. Validate each model with `npx likec4 validate `. +5. Rerun the script; the report should show no errors, and only warnings you have explained. diff --git a/skills/purview-site-assessment/references/rules.md b/skills/purview-site-assessment/references/rules.md new file mode 100644 index 0000000..245e585 --- /dev/null +++ b/skills/purview-site-assessment/references/rules.md @@ -0,0 +1,79 @@ +# Assessment rules + +Severity is what the finding costs: an **error** breaks the site's sync or build, or loses content; a +**warning** is visible to readers; an **info** is an improvement. + +## Repository + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| REPO-001 | error | no `README.md` | add one: what it is, install, a minimal example, a link into the docs | + +## Documentation + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| DOCS-001 | warning / info | no docs folder; warning when the README is long enough to be a docs tree (≥ 8 `##` sections or ≥ 300 lines) | create `docs/wiki/` with topic folders; keep the README as the front door | +| DOCS-002 | warning | no `Home.md`/`index.md`/`README.md` at the docs root | add `Home.md`, or have the entry set `docs.rootPage` | +| DOCS-003 | warning / info | a flat folder of 7+ pages, or 10+ pages left at the root beside folders | group into topic folders; keep only the hub, getting-started and deep-linked pages at the root | +| DOCS-004 | info | folders more than two levels deep | flatten unless a sub-area has several pages of its own | +| DOCS-005 | error / warning | a path that reduces to an unpublishable slug (error), a name the site has to rewrite (warning), or a page or folder named `prerelease` (error) | letters, digits and single dashes | +| DOCS-006 | error | two files publish at the same URL | rename one | +| DOCS-007 | warning | no `#` title, or more than one | exactly one `# Title` first | +| DOCS-008 | error | a relative link to a file that does not exist | fix the path; it resolves from the page's own folder | +| DOCS-009 | error | a relative image that does not exist | fix the path or add the image | +| DOCS-010 | info | a fence with no language | label it | +| DOCS-011 | info / warning | no `_Sidebar.md` (info), a sidebar link to a missing page (warning), a page not in the sidebar (info) | list every page once, by section | +| DOCS-012 | info | `mkdocs.yml` present | delete it; the site ignores it | +| DOCS-013 | error | a code fence never closed | close it | + +## Diagrams + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| DIAG-001 | warning | ASCII-art diagram in a text fence | LikeC4 model + Mermaid fence in a `purview:likec4` block; legends become tables | +| DIAG-002 | info | a ```` ```mermaid ```` fence with no LikeC4 pair | fine as is; pair architecture and flow diagrams | +| DIAG-003 | warning | a bare ```` ```likec4 ```` fence — GitHub shows source | move the model to a `.c4` file and use a pair | +| DIAG-004 | error | a malformed pair: unclosed or orphan marker, no `file`, a model that is missing or outside the repository, or a `view` the model does not declare | fix the marker or the model; the site shows the Mermaid until then | +| DIAG-005 | warning | a model that imports or has no `specification` | one self-contained file per diagram | +| DIAG-006 | warning | a pair with no Mermaid fence, so GitHub shows nothing | add the Mermaid rendering between the markers | +| DIAG-007 | info | a pair that does not name its `view` | add `view=""` | +| DIAG-008 | warning | Mermaid source inside a ```` ```text ```` fence | label it ```` ```mermaid ````; both renderers draw it | + +The text-fence classifier skips folder trees (`├──`, `└──`), which are listings rather than diagrams. + +## Redirects + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| RED-001 | error | `_redirects.yml` unreadable, or a pair that cannot become a URL or points at itself — the site's sync fails | one `previous: current` per line under `redirects:` | +| RED-002 | warning | a destination that is not a page | point at the page's current path | +| RED-003 | info | a source that is still a page, so the redirect is inactive | expected while the move is unreleased | +| RED-004 | warning | a page renamed or deleted in git history with no redirect (here or in the catalogue's `docs.redirects`) | add the pair the finding suggests, unless the page was never published | +| RED-005 | warning | a chain (`a → b`, `b → c`) | point every old slug at the final page | + +RED-004 reads the last 300 commits that touched the docs folder. A page that existed only between two +site builds was never published; ignore those. + +## Catalogue (with `--catalogue`) + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| CAT-001 | error | the entry's `docs.path` (or `--docs`) has no Markdown | create it, or correct the entry | +| CAT-002 | error | the entry's `docs.rootPage` does not exist | restore the page, or change the entry in the same release | +| CAT-003 | error | a use case's `docsPage` is not a page — the site's build fails | keep the page at the root under its name, or move the deep link | +| CAT-004 | info | the entry carries `docs.redirects` | move them into `_redirects.yml`, then drop them from the entry | + +## Agent skills + +| Rule | Severity | Finding | Fix | +| -- | -- | -- | -- | +| SKILL-001 | error | a `skills//` folder with no `SKILL.md` | add it, or move the folder | +| SKILL-002 | error | no front matter, or no `name` / `description` | add them | +| SKILL-003 | error | `name` differs from the folder | make them equal | +| SKILL-004 | warning | `name` is not a lower-case slug | rename | +| SKILL-005 | error | `SKILL.md` links a file that is not in the skill folder | ship it beside `SKILL.md` — only the folder is installed | + +## Metadata (checked by hand with `gh`) + +META-001 … META-006 are listed in `SKILL.md`, step 3. diff --git a/skills/purview-site-assessment/scripts/assess.mjs b/skills/purview-site-assessment/scripts/assess.mjs new file mode 100644 index 0000000..644c8b5 --- /dev/null +++ b/skills/purview-site-assessment/scripts/assess.mjs @@ -0,0 +1,669 @@ +#!/usr/bin/env node +// Assess a repository's readiness for purview.dev. +// +// node assess.mjs [repository-root] [--docs ] [--catalogue ] [--json] +// +// Reads files only (plus `git log`, when git is available). No network, no +// dependencies, Node 18+. Exit code 1 when an error-level finding exists. +// +// `--catalogue` is a local copy of this project's purview.dev catalogue entry +// (purview-dev/purview-dev: src/src/data/projects/.yml). With it, the +// docs path, root page, deep links and catalogue redirects are checked too. +// +// The rules mirror what purview.dev's docs sync does (purview-dev/purview-dev, +// src/src/lib/docs/). Each finding names a rule id the SKILL.md explains. + +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path'; + +// --------------------------------------------------------------------------- +// Arguments +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +const json = args.includes('--json'); +const option = (name) => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +}; +const VALUE_OPTIONS = new Set(['--docs', '--catalogue']); +const root = resolve( + args.find((arg, index) => !arg.startsWith('--') && !VALUE_OPTIONS.has(args[index - 1])) ?? '.', +); +const cataloguePath = option('--catalogue'); + +/** + * The few catalogue fields the checks need, read line by line from the entry's + * fixed layout (no YAML dependency): docs source/path/rootPage, docs.redirects + * and every use case's docsPage. + */ +function readCatalogueEntry(path) { + const lines = readFileSync(path, 'utf8').replace(/\r\n?/g, '\n').split('\n'); + const entry = { source: undefined, path: undefined, rootPage: undefined, redirects: {}, docsPages: [] }; + let section = null; + let sectionIndent = 0; + for (const line of lines) { + const indent = line.length - line.trimStart().length; + const text = line.trim().replace(/\s+#.*$/, ''); + if (text === '' || text.startsWith('#')) continue; + if (indent === 0) { + section = text === 'docs:' ? 'docs' : null; + continue; + } + if (section === 'docs' && text === 'redirects:') { + section = 'redirects'; + sectionIndent = indent; + continue; + } + if (section === 'redirects') { + if (indent <= sectionIndent) { + section = 'docs'; + } else { + const pair = /^["']?([^"':]+)["']?\s*:\s*["']?([^"']+?)["']?$/.exec(text); + if (pair) entry.redirects[pair[1].trim()] = pair[2].trim(); + continue; + } + } + if (section === 'docs') { + const field = /^(source|path|rootPage):\s*["']?([^"']+?)["']?$/.exec(text); + if (field && indent === 2) entry[field[1]] = field[2]; + } + const deepLink = /^docsPage:\s*["']?([^"'\s]+)["']?$/.exec(text); + if (deepLink) entry.docsPages.push(deepLink[1]); + } + return entry; +} + +const catalogue = cataloguePath ? readCatalogueEntry(resolve(cataloguePath)) : null; +const docsOverride = option('--docs') ?? (catalogue?.source === 'github-path' ? catalogue.path : undefined); + +/** @type {{ id: string; severity: 'error' | 'warning' | 'info'; file?: string; line?: number; message: string; fix: string }[]} */ +const findings = []; +const report = (id, severity, file, line, message, fix) => + findings.push({ id, severity, file, line, message, fix }); + +// --------------------------------------------------------------------------- +// File helpers +// --------------------------------------------------------------------------- + +const toPosix = (path) => path.split(sep).join('/'); +const repoPath = (absolute) => toPosix(relative(root, absolute)); +const read = (absolute) => readFileSync(absolute, 'utf8').replace(/\r\n?/g, '\n'); +const IGNORED_DIRS = new Set(['.git', 'node_modules', 'bin', 'obj', 'dist', '.vs', '.idea', 'artifacts']); + +function walk(directory, predicate, found = []) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolute = join(directory, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) { + walk(absolute, predicate, found); + } + } else if (predicate(absolute)) { + found.push(absolute); + } + } + return found; +} + +// --------------------------------------------------------------------------- +// Slugs — the same reduction purview.dev applies (src/src/lib/docs/links.ts) +// --------------------------------------------------------------------------- + +const reduceSegment = (segment) => + segment + .replace(/[^a-zA-Z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); + +function docSlug(relativePath) { + const segments = relativePath.split('/').filter((segment) => segment !== '' && segment !== '.'); + return segments + .map((segment, index) => { + if (index < segments.length - 1) { + return reduceSegment(segment); + } + const name = segment.replace(/\.(md|mdx)$/i, ''); + return segments.length > 1 && /^readme$/i.test(name) ? 'index' : reduceSegment(name); + }) + .join('/'); +} + +const SAFE_SEGMENT = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; +const isIndexName = (name) => /^(home|index|readme)\.md$/i.test(name); + +// --------------------------------------------------------------------------- +// Markdown helpers +// --------------------------------------------------------------------------- + +/** Lines with fenced code blanked (fences reported separately), so headings and links are read from prose only. */ +function scanFences(text) { + const lines = text.split('\n'); + const fences = []; + const prose = [...lines]; + let open = null; + lines.forEach((line, index) => { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (!open && match) { + open = { start: index, run: match[1], info: match[2].trim(), body: [] }; + prose[index] = ''; + return; + } + if (open) { + if (match && match[1][0] === open.run[0] && match[1].length >= open.run.length && match[2].trim() === '') { + fences.push({ ...open, end: index }); + open = null; + } else { + open.body.push(line); + } + prose[index] = ''; + } + }); + if (open) { + fences.push({ ...open, end: lines.length - 1, unclosed: true }); + } + return { lines, prose, fences }; +} + +const stripInlineCode = (line) => line.replace(/`+[^`]*`+/g, ''); + +const MERMAID_START = /^\s*(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(-v2)?|erDiagram|gantt|journey|mindmap|timeline|C4Context|C4Container|C4Component|gitGraph|pie|quadrantChart|block-beta|architecture-beta)\b/; + +/** + * What an unlabelled or `text` fence holds: `mermaid` source (the old + * "Mermaid goes in a text block" convention), a folder `tree` (a listing, not a + * diagram), an ASCII-art `diagram`, or null for ordinary text. + */ +function classifyTextFence(fence) { + const language = fence.info.split(/\s+/)[0].toLowerCase(); + if (!['', 'text', 'txt', 'plaintext', 'ascii', 'none'].includes(language)) { + return null; + } + const body = fence.body.filter((line) => line.trim() !== ''); + if (body.length === 0) { + return null; + } + if (MERMAID_START.test(body[0])) { + return 'mermaid'; + } + const treeLines = body.filter((line) => /[├└]──|^\s*│|^\s*[\w.<>-]+\/\s*$/.test(line)).length; + if (treeLines >= Math.ceil(body.length / 2)) { + return 'tree'; + } + if (body.length < 3) { + return null; + } + const boxDrawing = body.some((line) => /[┌┐┘┬┴┼═║╔╗╚╝▶▼◀▲]/.test(line)); + const arrows = body.filter((line) => /(-{1,}>|<-{1,}|={1,}>|\+-{2,}|-{3,}\+|→|↓)/.test(line)).length; + const connectors = body.filter( + (line) => /^\s*[|^v+]\s*($|\s{2,})|\s{2,}[|^v+]\s*$/.test(line) || /\s[|^]\s{3,}[|^]/.test(line), + ).length; + return boxDrawing || (arrows >= 1 && connectors >= 1) || arrows >= 3 ? 'diagram' : null; +} + +// --------------------------------------------------------------------------- +// Diagram pairs — mirrors src/src/lib/docs/diagrams.ts +// --------------------------------------------------------------------------- + +const DIAGRAM_OPEN = /^ {0,3}\s*$/; +const DIAGRAM_CLOSE = /^ {0,3}\s*$/; + +function parseOptions(source) { + const options = {}; + for (const match of source.matchAll(/([A-Za-z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/g)) { + options[match[1]] = match[2] ?? match[3] ?? match[4] ?? ''; + } + return options; +} + +function declaredViews(source) { + return new Set( + [...source.matchAll(/(?:^|[\s{;])(?:dynamic\s+|deployment\s+)?view\s+([A-Za-z_][\w-]*)/g)].map((m) => m[1]), + ); +} + +function checkDiagramBlocks(file, scan) { + const page = repoPath(file); + const inFence = new Set(); + for (const fence of scan.fences) { + for (let index = fence.start; index <= fence.end; index++) inFence.add(index); + } + const blocks = []; + let open = null; + scan.lines.forEach((line, index) => { + if (inFence.has(index)) return; + const opening = DIAGRAM_OPEN.exec(line); + if (opening) { + if (open) { + report('DIAG-004', 'error', page, open.line, 'A purview:likec4 block is not closed before the next one opens.', 'Add `` after the Mermaid fence.'); + } + open = { line: index + 1, start: index, options: parseOptions(opening[1]) }; + } else if (DIAGRAM_CLOSE.test(line)) { + if (!open) { + report('DIAG-004', 'error', page, index + 1, 'A closing purview:likec4 marker has no opening marker.', 'Remove it, or add the opening marker with file="…" and view="…".'); + } else { + blocks.push({ ...open, end: index }); + open = null; + } + } + }); + if (open) { + report('DIAG-004', 'error', page, open.line, 'A purview:likec4 block is never closed.', 'Add `` after the Mermaid fence.'); + } + + for (const block of blocks) { + const inner = scan.fences.filter((fence) => fence.start > block.start && fence.end < block.end); + if (!inner.some((fence) => fence.info.split(/\s+/)[0] === 'mermaid')) { + report('DIAG-006', 'warning', page, block.line, 'The diagram block has no ```mermaid fence, so GitHub readers see no diagram.', 'Put the Mermaid rendering of the view between the markers.'); + } + const file = block.options.file; + if (!file) { + report('DIAG-004', 'error', page, block.line, 'The purview:likec4 marker has no file="…"; the site will show the Mermaid instead.', 'Add file="./diagrams/.c4" (relative to the page).'); + continue; + } + const target = file.startsWith('/') ? posix.normalize(file.slice(1)) : posix.normalize(posix.join(posix.dirname(page), file)); + if (target.startsWith('..')) { + report('DIAG-004', 'error', page, block.line, `file="${file}" leaves the repository.`, 'Keep the model inside the repository.'); + continue; + } + const absolute = join(root, target); + if (!existsSync(absolute)) { + report('DIAG-004', 'error', page, block.line, `file="${file}" does not exist (${target}); the site will show the Mermaid instead.`, 'Create the model or fix the path (it is relative to the page).'); + continue; + } + const model = read(absolute); + const views = declaredViews(model); + if (block.options.view && !views.has(block.options.view)) { + report('DIAG-004', 'error', page, block.line, `${target} declares no view "${block.options.view}" (declared: ${[...views].join(', ') || 'none'}).`, 'Name an existing view, or add it to the model.'); + } else if (!block.options.view) { + report('DIAG-007', 'info', page, block.line, 'The diagram block does not name a view, so the first view in the model is shown.', 'Add view="" so a new view added first does not change this page.'); + } + if (/^\s*import\s/m.test(model) || !/\bspecification\s*\{/.test(model)) { + report('DIAG-005', 'warning', target, 1, 'The model is not self-contained (it imports, or has no specification block). The site compiles each file on its own.', 'Give the file its own `specification`, `model` and `views`, and remove imports.'); + } + } + return blocks; +} + +// --------------------------------------------------------------------------- +// `_redirects.yml` — a tolerant reader for the simple shape the site documents +// --------------------------------------------------------------------------- + +function parseRedirects(text) { + const pairs = []; + const errors = []; + text.split('\n').forEach((raw, index) => { + const line = raw.replace(/\s+#.*$/, '').replace(/^#.*$/, ''); + if (line.trim() === '' || /^redirects:\s*$/.test(line.trim())) return; + const match = /^\s*(?:"([^"]+)"|'([^']+)'|([^:#\s][^:#]*?))\s*:\s*(?:"([^"]*)"|'([^']*)'|(.*?))\s*$/.exec(line); + if (!match) { + errors.push({ line: index + 1, message: `cannot read "${raw.trim()}" as \`previous: current\`` }); + return; + } + const from = match[1] ?? match[2] ?? match[3]; + const to = match[4] ?? match[5] ?? match[6]; + if (!to) { + errors.push({ line: index + 1, message: `"${from}" has no destination` }); + return; + } + pairs.push({ line: index + 1, from, to }); + }); + return { pairs, errors }; +} + +const redirectSlug = (value) => { + const trimmed = value.trim().replace(/^\/+|\/+$/g, ''); + return docSlug(/\.(md|mdx)$/i.test(trimmed) ? trimmed : `${trimmed}.md`); +}; + +// --------------------------------------------------------------------------- +// Repository-level checks +// --------------------------------------------------------------------------- + +if (!existsSync(root) || !statSync(root).isDirectory()) { + console.error(`Not a directory: ${root}`); + process.exit(2); +} + +const readmePath = ['README.md', 'readme.md', 'Readme.md'].map((name) => join(root, name)).find(existsSync); +if (!readmePath) { + report('REPO-001', 'error', undefined, undefined, 'No README.md at the repository root.', 'Add one: what the project is, install, a minimal example, a link into the docs.'); +} +for (const site of ['mkdocs.yml', 'mkdocs.yaml', 'docs/mkdocs.yml', 'docs/wiki/mkdocs.yml']) { + if (existsSync(join(root, site))) { + report('DOCS-012', 'info', site, undefined, `${site} is a site configuration purview.dev ignores.`, 'Delete it; the site reads the Markdown directly. Rewrite any Material-only markup it enabled as plain Markdown.'); + } +} + +// --------------------------------------------------------------------------- +// Documentation +// --------------------------------------------------------------------------- + +const docsCandidates = docsOverride ? [docsOverride] : ['docs/wiki', 'docs']; +const docsRootRelative = docsCandidates.find((candidate) => { + const absolute = join(root, candidate); + return existsSync(absolute) && statSync(absolute).isDirectory() && walk(absolute, (path) => path.endsWith('.md')).length > 0; +}); + +if (docsOverride && !docsRootRelative) { + report('CAT-001', 'error', docsOverride, undefined, `The docs folder "${docsOverride}" has no Markdown${catalogue ? " (the catalogue's docs.path)" : ''}; the site publishes nothing.`, 'Create the folder, or correct docs.path.'); +} + +/** Every Markdown file the checks read: the docs tree and the README. */ +const markdownFiles = []; +let pageSlugs = new Set(); + +if (!docsRootRelative) { + const readme = readmePath ? read(readmePath) : ''; + const sections = (readme.match(/^## /gm) ?? []).length; + const lines = readme.split('\n').length; + if (sections >= 8 || lines >= 300) { + report('DOCS-001', 'warning', 'README.md', undefined, `No docs folder, and the README has ${sections} sections over ${lines} lines — it is a documentation tree in one file.`, 'Move the reference material into docs/wiki/ topic folders (see purview-site-docs) and keep the README as the front door.'); + } else { + report('DOCS-001', 'info', undefined, undefined, 'No docs folder; purview.dev would publish README.md as the only page (docs.source: readme).', 'Fine for a small project. Create docs/wiki/ once the README outgrows one page.'); + } + if (readmePath) markdownFiles.push(readmePath); +} else { + const docsRoot = join(root, docsRootRelative); + const docFiles = walk(docsRoot, (path) => path.endsWith('.md')); + const relativeToDocs = (absolute) => toPosix(relative(docsRoot, absolute)); + const pages = docFiles.filter((path) => !/^_(Sidebar|Footer)\.md$/i.test(basename(path))); + markdownFiles.push(...pages); + if (readmePath && !pages.includes(readmePath)) markdownFiles.push(readmePath); + + // Root page: the catalogue's rootPage when it names one, else the conventional index. + const rootFiles = pages.filter((path) => dirname(path) === docsRoot); + const configuredRoot = catalogue?.rootPage + ? pages.find((path) => relativeToDocs(path).toLowerCase() === catalogue.rootPage.toLowerCase()) + : undefined; + if (catalogue?.rootPage && !configuredRoot) { + report('CAT-002', 'error', `${docsRootRelative}/${catalogue.rootPage}`, undefined, `The catalogue's docs.rootPage "${catalogue.rootPage}" does not exist; the site's sync fails.`, 'Restore the page, or ask the site maintainers to change docs.rootPage in the same release.'); + } + const index = configuredRoot ?? rootFiles.find((path) => isIndexName(basename(path))); + if (!index) { + report('DOCS-002', 'warning', docsRootRelative, undefined, 'No Home.md, index.md or README.md at the docs root.', 'Add Home.md as the hub, or have the catalogue entry name docs.rootPage.'); + } + + // Shape: flat corpus, depth. + const folders = new Set(pages.map((path) => dirname(relativeToDocs(path))).filter((folder) => folder !== '.')); + if (folders.size === 0 && pages.length >= 7) { + report('DOCS-003', 'warning', docsRootRelative, undefined, `${pages.length} pages in one flat folder.`, 'Group them into lower-case topic folders (guide/, configuration/, reference/); keep only the hub, getting-started and catalogue deep-linked pages at the root; add each move to _redirects.yml.'); + } else if (rootFiles.length >= 10 && folders.size > 0) { + report('DOCS-003', 'info', docsRootRelative, undefined, `${rootFiles.length} pages are still at the docs root beside ${folders.size} folder(s).`, 'Move pages into their topic folder unless the catalogue deep-links them (useCases[].docsPage).'); + } + for (const folder of folders) { + if (folder.split('/').length > 2) { + report('DOCS-004', 'info', `${docsRootRelative}/${folder}`, undefined, 'Folders nested more than two levels deep.', 'One level of topic folders is the default; nest only for a sub-area with several pages of its own.'); + } + } + + // Names and slugs. + const bySlug = new Map(); + for (const path of pages) { + const relativePath = relativeToDocs(path); + const slug = path === index ? 'index' : docSlug(relativePath); + const segments = slug.split('/'); + if (segments.some((segment) => !SAFE_SEGMENT.test(segment))) { + report('DOCS-005', 'error', repoPath(path), undefined, `"${relativePath}" reduces to the unpublishable slug "${slug}"; the site's sync fails on it.`, 'Rename every folder and the file with letters, digits and dashes.'); + } else { + const unsafe = relativePath.replace(/\.md$/i, '').split('/').filter((segment) => !/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(segment)); + if (unsafe.length > 0) { + report('DOCS-005', 'warning', repoPath(path), undefined, `"${unsafe.join('/')}" is rewritten to publish (${slug}).`, 'Rename with letters, digits and single dashes so the file name and the URL agree.'); + } + } + if (slug === 'prerelease' || slug.startsWith('prerelease/')) { + report('DOCS-005', 'error', repoPath(path), undefined, '"prerelease" is reserved for the prerelease documentation channel.', 'Rename the page or folder.'); + } + const existing = bySlug.get(slug); + if (existing) { + report('DOCS-006', 'error', repoPath(path), undefined, `Publishes at the same URL as ${repoPath(existing)} (${slug}).`, 'Rename one of them.'); + } else { + bySlug.set(slug, path); + } + } + pageSlugs = new Set(bySlug.keys()); + + // Catalogue deep links: a use case's docsPage that is not a page fails the site's catalogue check. + for (const docsPage of catalogue?.docsPages ?? []) { + if (!pageSlugs.has(docsPage)) { + report('CAT-003', 'error', docsRootRelative, undefined, `The catalogue deep-links "${docsPage}" (useCases[].docsPage), which is not a page here; the site's build fails.`, 'Keep that page at the docs root under its name, or ask the site maintainers to update the use case in the same release.'); + } + } + if (catalogue && Object.keys(catalogue.redirects).length > 0) { + report('CAT-004', 'info', `${docsRootRelative}/_redirects.yml`, undefined, `The catalogue entry carries ${Object.keys(catalogue.redirects).length} docs.redirects pair(s).`, 'Move them into _redirects.yml here, then ask the site maintainers to drop them from the entry.'); + } + + // Sidebar. + const sidebar = docFiles.find((path) => /^_Sidebar\.md$/i.test(basename(path))); + if (!sidebar && pages.length > 3) { + report('DOCS-011', 'info', docsRootRelative, undefined, 'No _Sidebar.md; pages are ordered alphabetically on the site.', 'Add _Sidebar.md listing every page once, grouped by section, in reading order.'); + } else if (sidebar) { + const listed = new Set(); + const text = read(sidebar); + for (const match of text.matchAll(/\]\(([^)\s#]+\.md)(?:#[^)]*)?\)/g)) { + const target = posix.normalize(match[1]); + listed.add(target.toLowerCase()); + if (!existsSync(join(docsRoot, target))) { + report('DOCS-011', 'warning', repoPath(sidebar), undefined, `Lists ${match[1]}, which does not exist.`, 'Fix or remove the entry.'); + } + } + for (const path of pages) { + const relativePath = relativeToDocs(path); + if (path !== index && !listed.has(relativePath.toLowerCase())) { + report('DOCS-011', 'info', repoPath(path), undefined, 'Not listed in _Sidebar.md, so it sorts last on the site.', 'Add it to _Sidebar.md under its section.'); + } + } + } + + // Redirects. + const redirectsFile = join(docsRoot, '_redirects.yml'); + const redirected = new Set(); + if (existsSync(redirectsFile)) { + const { pairs, errors } = parseRedirects(read(redirectsFile)); + for (const error of errors) { + report('RED-001', 'error', repoPath(redirectsFile), error.line, `${error.message}; the site's sync fails on an unreadable redirects file.`, 'Use one `previous: current` pair per line under `redirects:`.'); + } + for (const pair of pairs) { + const from = redirectSlug(pair.from); + const to = redirectSlug(pair.to); + redirected.add(from); + if (from.split('/').some((segment) => !SAFE_SEGMENT.test(segment)) || to.split('/').some((segment) => !SAFE_SEGMENT.test(segment))) { + report('RED-001', 'error', repoPath(redirectsFile), pair.line, `"${pair.from}: ${pair.to}" does not reduce to publishable slugs.`, 'Use slugs or Markdown paths from the docs root.'); + } else if (from === to) { + report('RED-001', 'error', repoPath(redirectsFile), pair.line, `"${pair.from}" redirects to itself.`, 'Remove the line.'); + } else if (!pageSlugs.has(to)) { + report('RED-002', 'warning', repoPath(redirectsFile), pair.line, `"${pair.from}" points at "${to}", which is not a page; no redirect is written.`, 'Point it at the page\'s current path.'); + } else if (pageSlugs.has(from)) { + report('RED-003', 'info', repoPath(redirectsFile), pair.line, `"${from}" is still a page, so this redirect stays inactive.`, 'Expected only while the move is unreleased; otherwise remove the old page.'); + } + } + const sources = new Set(pairs.map((pair) => redirectSlug(pair.from))); + for (const pair of pairs) { + if (sources.has(redirectSlug(pair.to))) { + report('RED-005', 'warning', repoPath(redirectsFile), pair.line, `"${pair.from}" points at "${pair.to}", which itself redirects.`, 'Point every old slug at the final page.'); + } + } + } + + // Moves in git history that left no redirect (the catalogue's docs.redirects counts, when given). + for (const from of Object.keys(catalogue?.redirects ?? {})) { + redirected.add(redirectSlug(from)); + } + try { + const log = execFileSync( + 'git', + ['-C', root, 'log', '--max-count=300', '--find-renames', '--diff-filter=RD', '--name-status', '--format=', '--', docsRootRelative], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const missing = new Map(); + const fromDocs = (path) => path.slice(docsRootRelative.length + 1); + for (const line of log.split('\n')) { + const [status, oldPath, newPath] = line.split('\t'); + if (!status || !oldPath || !oldPath.endsWith('.md') || !oldPath.startsWith(`${docsRootRelative}/`)) continue; + if (/^_/.test(basename(oldPath)) || isIndexName(basename(oldPath))) continue; + const oldSlug = docSlug(fromDocs(oldPath)); + if (pageSlugs.has(oldSlug) || redirected.has(oldSlug) || missing.has(oldSlug)) continue; + const newSlug = + status.startsWith('R') && newPath?.startsWith(`${docsRootRelative}/`) ? docSlug(fromDocs(newPath)) : undefined; + missing.set(oldSlug, newSlug && pageSlugs.has(newSlug) ? newSlug : undefined); + } + for (const [slug, current] of missing) { + report( + 'RED-004', + 'warning', + `${docsRootRelative}/_redirects.yml`, + undefined, + current + ? `"${slug}" was renamed to "${current}" in git history and has no redirect.` + : `"${slug}" was moved or deleted in git history and has no redirect.`, + current + ? `Add \`${slug}: ${current}\` to _redirects.yml (unless it was never published).` + : `If it was published, add \`${slug}: \` to _redirects.yml. Pass --catalogue to count the site's docs.redirects.`, + ); + } + } catch { + // Not a git checkout, or git is unavailable: the history check is best-effort. + } +} + +// --------------------------------------------------------------------------- +// Per-page checks (docs pages and the README) +// --------------------------------------------------------------------------- + +for (const file of markdownFiles) { + const page = repoPath(file); + const scan = scanFences(read(file)); + + const titles = []; + scan.prose.forEach((line, index) => { + if (/^#\s+\S/.test(line)) titles.push(index + 1); + }); + if (titles.length === 0) { + report('DOCS-007', 'warning', page, 1, 'No `# Title` heading; the site falls back to the file name.', 'Open the page with exactly one `# Title`.'); + } else if (titles.length > 1) { + report('DOCS-007', 'warning', page, titles[1], `${titles.length} \`#\` headings; the site keeps the first as the title and demotes the rest.`, 'Use one `#` title and `##` for sections.'); + } + + scan.prose.forEach((rawLine, index) => { + const line = stripInlineCode(rawLine); + for (const match of line.matchAll(/(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { + const target = match[2]; + if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/|<)/i.test(target)) continue; + const path = decodeURI(target.split('#')[0].split('?')[0]); + if (!path) continue; + const absolute = path.startsWith('/') ? join(root, path) : join(dirname(file), path); + if (!existsSync(absolute)) { + const isImage = match[1] === '!'; + report(isImage ? 'DOCS-009' : 'DOCS-008', 'error', page, index + 1, `${isImage ? 'Image' : 'Link'} target ${target} does not exist.`, 'Fix the path — it resolves from the folder this page is in.'); + } + } + }); + + for (const fence of scan.fences) { + const language = fence.info.split(/\s+/)[0]; + if (fence.unclosed) { + report('DOCS-013', 'error', page, fence.start + 1, 'A code fence is never closed; everything after it renders as code.', `Close it with ${fence.run}.`); + } + if (!language) { + report('DOCS-010', 'info', page, fence.start + 1, 'A code fence has no language.', 'Label it (`csharp`, `json`, `shell`, `text`).'); + } + const kind = classifyTextFence(fence); + if (kind === 'diagram') { + report('DIAG-001', 'warning', page, fence.start + 1, 'An ASCII-art diagram.', 'Replace it with a LikeC4 model plus a Mermaid fence in a purview:likec4 block (purview-site-docs, references/diagrams.md). Move any legend into a table.'); + } else if (kind === 'mermaid') { + report('DIAG-008', 'warning', page, fence.start + 1, 'Mermaid source in a text fence, so neither GitHub nor the site draws it.', 'Label the fence ```mermaid — both render it — and, for an architecture or flow diagram, pair it with a LikeC4 model in a purview:likec4 block.'); + } + } + + const blocks = checkDiagramBlocks(file, scan); + const insideBlock = (line) => blocks.some((block) => line > block.start && line < block.end); + for (const fence of scan.fences) { + const language = fence.info.split(/\s+/)[0]; + if (insideBlock(fence.start)) continue; + if (language === 'likec4' || language === 'c4') { + if (language === 'likec4') { + report('DIAG-003', 'warning', page, fence.start + 1, 'A bare ```likec4 fence: GitHub shows it as source code.', 'Move the model to a .c4 file and use a purview:likec4 block with a Mermaid fence for GitHub.'); + } + } else if (language === 'mermaid') { + report('DIAG-002', 'info', page, fence.start + 1, 'A Mermaid diagram with no LikeC4 pair. Both GitHub and the site render it.', 'Optional: for an architecture or flow diagram, add a LikeC4 model and wrap this fence in a purview:likec4 block.'); + } + } +} + +// --------------------------------------------------------------------------- +// Agent skills +// --------------------------------------------------------------------------- + +const skillsRoot = join(root, 'skills'); +if (existsSync(skillsRoot) && statSync(skillsRoot).isDirectory()) { + for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const folder = `skills/${entry.name}`; + const skillFile = join(skillsRoot, entry.name, 'SKILL.md'); + if (!existsSync(skillFile)) { + report('SKILL-001', 'error', folder, undefined, 'A skills folder with no SKILL.md.', 'Add SKILL.md, or move the folder out of skills/.'); + continue; + } + const text = read(skillFile); + const frontMatter = /^---\n([\s\S]*?)\n---/.exec(text)?.[1]; + if (!frontMatter) { + report('SKILL-002', 'error', `${folder}/SKILL.md`, 1, 'No YAML front matter.', 'Start the file with `---`, `name:` and `description:`, then `---`.'); + continue; + } + const name = /^name:\s*["']?([^"'\n]+?)["']?\s*$/m.exec(frontMatter)?.[1]; + const description = /^description:\s*(.*)$/m.exec(frontMatter)?.[1]; + if (!name) { + report('SKILL-002', 'error', `${folder}/SKILL.md`, 1, 'Front matter has no `name`.', `Add \`name: ${entry.name}\`.`); + } else if (name !== entry.name) { + report('SKILL-003', 'error', `${folder}/SKILL.md`, 1, `name "${name}" does not match the folder "${entry.name}".`, 'Make them equal; the CLI and the site key the skill on the folder.'); + } + if (name && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + report('SKILL-004', 'warning', `${folder}/SKILL.md`, 1, `name "${name}" is not a lower-case slug.`, 'Use lower-case letters, digits and dashes.'); + } + if (description === undefined || description.trim() === '') { + report('SKILL-002', 'error', `${folder}/SKILL.md`, 1, 'Front matter has no `description`.', 'Say what the skill does and when to use it, in one paragraph.'); + } + const scan = scanFences(text); + scan.prose.forEach((line, index) => { + for (const match of stripInlineCode(line).matchAll(/\]\(([^)\s#:]+)(?:#[^)]*)?\)/g)) { + if (!existsSync(join(skillsRoot, entry.name, match[1]))) { + report('SKILL-005', 'error', `${folder}/SKILL.md`, index + 1, `Links ${match[1]}, which is not in the skill folder.`, 'Ship the file beside SKILL.md, or fix the link.'); + } + } + }); + } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +const order = { error: 0, warning: 1, info: 2 }; +findings.sort((a, b) => order[a.severity] - order[b.severity] || a.id.localeCompare(b.id) || String(a.file).localeCompare(String(b.file))); +const counts = { error: 0, warning: 0, info: 0 }; +for (const finding of findings) counts[finding.severity] += 1; + +if (json) { + console.log(JSON.stringify({ root, docsRoot: docsRootRelative ?? null, pages: pageSlugs.size, counts, findings }, null, 2)); +} else { + const lines = [ + `# purview.dev assessment: ${basename(root)}`, + '', + `Docs root: ${docsRootRelative ?? '(none — README only)'} · pages: ${pageSlugs.size || (readmePath ? 1 : 0)} · ` + + `${counts.error} error(s), ${counts.warning} warning(s), ${counts.info} note(s)`, + '', + ]; + if (findings.length === 0) { + lines.push('No findings.'); + } else { + lines.push('| Severity | Rule | Where | Finding | Fix |', '| -- | -- | -- | -- | -- |'); + const cell = (value) => String(value).replace(/\|/g, '\\|').replace(/\n/g, ' '); + for (const finding of findings) { + const where = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ''}` : '—'; + lines.push(`| ${finding.severity} | ${finding.id} | ${cell(where)} | ${cell(finding.message)} | ${cell(finding.fix)} |`); + } + } + console.log(lines.join('\n')); +} + +process.exit(counts.error > 0 ? 1 : 0); diff --git a/skills/purview-site-docs/SKILL.md b/skills/purview-site-docs/SKILL.md new file mode 100644 index 0000000..9e916d3 --- /dev/null +++ b/skills/purview-site-docs/SKILL.md @@ -0,0 +1,117 @@ +--- +name: purview-site-docs +description: Write and reorganise a Purview repository's documentation so purview.dev publishes it cleanly - a hierarchical docs/wiki tree (topic folders, not one flat folder), one H1 per page, relative links that resolve from the page they are in, GitHub alerts, labelled fences, LikeC4 diagrams paired with a Mermaid fence so GitHub and the site each render the one they can, and a _redirects.yml in the docs root that keeps old URLs working when pages move. Use when adding, moving, renaming or splitting documentation pages, when drawing a diagram (never ASCII art), when a docs folder has grown flat, or when asked how a page will look on purview.dev. +license: MIT +metadata: + site: https://purview.dev + repository: purview-dev/purview-dev +--- + +# Documentation that publishes cleanly on purview.dev + +purview.dev does not host your documentation; it **reads it out of your repository** at build time +and publishes it at `https://purview.dev/docs//`. The same Markdown is also read on +GitHub. Every rule here keeps one file correct in both places, with no site configuration in your +repository. + +The site's catalogue entry for your project (in `purview-dev/purview-dev`, +`src/src/data/projects/.yml`) decides **which** files are read — `docs.source`, `docs.path`, +`docs.rootPage`, `docs.exclude`. This repository decides **what they say and where they sit**. + +## The rules + +| # | Rule | Why | +| - | - | - | +| 1 | Docs live in `docs/wiki/` (or `docs/`), as plain `.md` files. No `mkdocs.yml`, theme or plugin list. | The site renders; the repository only writes. A site file is ignored and drifts. | +| 2 | **Hierarchical, not flat**: one lower-case topic folder per area (`guide/`, `configuration/`, `reference/`). Only the landing page, `Home.md`, `_Sidebar.md` and pages the catalogue deep-links stay at the root. | A folder is a sidebar group and a URL segment: `guide/Getting-Started.md` → `/docs//guide/getting-started/`. Twenty files in one folder is a list nobody scans. | +| 3 | File and folder names: letters, digits, single dashes (`Custom-Rules.md`). | The path *is* the URL, lower-cased; anything else is rewritten, and a name that reduces to nothing fails the sync. | +| 4 | Exactly one `# Title` per page, first; then one paragraph saying what the page answers. | The H1 is the page title and sidebar label; the first paragraph is the meta description (160 chars). | +| 5 | Relative links, resolved from the file they are in (`../reference/Options.md`, `Sibling.md#anchor`). | The site rewrites them to its own URLs; a link outside the docs folder becomes a GitHub link. | +| 6 | Relative images (`./images/flow.png`); badges may stay absolute. | Relative images are served from GitHub raw at the channel's ref. | +| 7 | Every fence declares a language (`csharp`, `json`, `shell`, `text`). | An unlabelled fence loses highlighting on both sides. | +| 8 | Callouts use GitHub alerts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`. | They become Starlight asides on the site and render natively on GitHub. | +| 9 | **Diagrams are never ASCII art.** A diagram is a LikeC4 model paired with a Mermaid fence (below). | ASCII art is unreadable on a phone, unsearchable, and drifts silently. | +| 10 | Moving or renaming a page adds a line to `_redirects.yml` in the same commit. | The old URL is in bookmarks, search results and other repositories. | +| 11 | `_Sidebar.md` lists every page once, grouped by section, in reading order. | The site orders pages by it; a page missing from it has no place in the navigation. | + +Full layout rules, a worked tree and what the site rewrites: [references/layout.md](references/layout.md). + +## Hierarchy: the default shape + +```text +docs/wiki/ + _Sidebar.md navigation (not published) + _redirects.yml moves (not published) + Home.md hub / landing page (docs.rootPage) + Getting-Started.md root only because the catalogue deep-links it + guide/ + README.md the folder's own page: /docs//guide/ + Installation.md + First-Project.md + configuration/ + Options.md + Environment-Variables.md + diagrams/ + request-flow.c4 LikeC4 models (not published as pages) +``` + +One level of folders is the default; go deeper only when a folder holds a sub-area with several pages +of its own. A nested `README.md` (or `index.md`) is that folder's landing page. A page the site's +catalogue deep-links from a use case (`useCases[].docsPage`) is a single URL segment, so it must stay +at the root until the catalogue entry moves with it — check the entry before moving one. + +## Diagrams: LikeC4 on the site, Mermaid on GitHub + +Draw each diagram once as a LikeC4 model in a `.c4` file, and once as Mermaid for GitHub. Wrap the +Mermaid fence in the `purview:likec4` markers: + +````markdown + +```mermaid +flowchart LR + client[Client] --> api[API] --> store[(Store)] +``` + +[LikeC4 model](./diagrams/request-flow.c4) + +```` + +- **On GitHub** the comments are invisible: the reader sees the Mermaid diagram and a link to the model. +- **On purview.dev** the sync replaces the whole block — markers, Mermaid and link — with the LikeC4 + view, rendered interactively. The Mermaid is never shown there. +- If the model cannot be read or does not declare the view, the site keeps the Mermaid and the sync + logs a warning, so a mistake costs fidelity, never the page. + +Marker options, the model rules (one self-contained file per diagram), and a full worked conversion of an +ASCII flow: [references/diagrams.md](references/diagrams.md). + +## Moving pages: `_redirects.yml` + +```yaml +# docs/wiki/_redirects.yml — previous: current (slugs, or paths from the docs root) +redirects: + getting-started: guide/getting-started + Custom-Rules.md: schemas/Custom-Rules.md +``` + +The site reads the file at each documentation channel's own ref and writes a redirect page for every +pair whose destination exists and whose source is no longer a page. A pair whose destination is missing, +or whose source still exists, is reported and skipped; an invalid file fails the site's sync with the +file name, so fix it rather than delete it. Never remove a line once released — the old URL lives in +someone's bookmark. Details: [references/redirects.md](references/redirects.md). + +## Moving a page — the checklist + +1. `git mv` the file, so history follows it. +2. Fix every inbound link: other pages (relative to *their* folder), `_Sidebar.md`, `Home.md`, the + READMEs, `AGENTS.md`, skills, and tests that name the path. +3. Add `old-slug: new/slug` to `_redirects.yml`. +4. If the page is a catalogue deep link (`docsPage`), it stays at the root, or ask the site maintainers + to move the deep link; do not edit `purview-dev/purview-dev` unasked. +5. Run the assessment (`purview-site-assessment`) and fix what it reports. + +## Related skills + +- `purview-site-integration` — everything else the site reads from a repository (topics, releases, + packages, skills) and how a project joins the catalogue. +- `purview-site-assessment` — a script and a procedure that check a repository against these rules. diff --git a/skills/purview-site-docs/references/diagrams.md b/skills/purview-site-docs/references/diagrams.md new file mode 100644 index 0000000..3710364 --- /dev/null +++ b/skills/purview-site-docs/references/diagrams.md @@ -0,0 +1,174 @@ +# Diagrams: a LikeC4 model paired with a Mermaid fence + +GitHub renders Mermaid and shows LikeC4 as source. purview.dev renders LikeC4 (interactive, with the +model behind it) and Mermaid. A diagram pair gives each reader the best diagram their renderer can show, +from one page. + +## The block + +````markdown + +```mermaid +flowchart LR + resume[pm-resume] --> next[pm-next] +``` + +[LikeC4 model](./diagrams/the-loop.c4) + +```` + +| Part | Rule | +| -- | -- | +| Opening marker | `` alone on its line | +| `file` | **required.** The `.c4` model, relative to the page (or `/`-rooted at the repository root). It may live anywhere in the repository, including outside the docs folder | +| `view` | the view to render; defaults to the first view the model declares. Name it explicitly | +| `title`, `description` | the heading and sub-heading of the diagram on the site | +| `height` | pixels, default `420` | +| `collapsible`, `defaultOpen` | `true`/`false`; the site's diagram shell is collapsible and open by default | +| Between the markers | what GitHub shows: one ```` ```mermaid ```` fence, then optionally a link to the model | +| Closing marker | `` alone on its line | + +Markers inside a code fence are ignored (that is how this page shows them). + +## What happens + +| Reader | Sees | +| -- | -- | +| GitHub | the Mermaid diagram and the "LikeC4 model" link; the comments are invisible | +| purview.dev | the LikeC4 view only. The sync swaps the whole block for a ```` ```likec4 ```` fence holding the model file's text | +| purview.dev, model unreadable or view undeclared | the Mermaid diagram, and a warning in the site's sync log naming the page, line and reason | + +## The model file + +- **One self-contained file per diagram**: its own `specification`, `model` and `views`. The site + compiles the file on its own, so it cannot see other `.c4` files — no shared specification across + files, no `import`. +- Keep models beside the docs (`docs/wiki/diagrams/.c4`). A `.c4` file is never published as a + page. +- Element and view ids: letters, digits, underscores, starting with a letter (`theLoop`, `apiGateway`). +- Validate before committing: `npx likec4 validate docs/wiki/diagrams` (or `bunx likec4 validate …`). +- Prefer `autoLayout LeftRight` for flows and `TopBottom` for hierarchies; `include *` for a small model. + +## The Mermaid rendering + +- It is the GitHub reader's diagram, so keep it equivalent: the same nodes and edges, the same labels. + It does not need the styling or descriptions the model carries. +- `flowchart LR`/`TB` for flows; `sequenceDiagram` when the LikeC4 view is a `dynamic view`. +- When the model changes, the Mermaid changes in the same commit — review them side by side. + +## Never ASCII art + +A fenced ```` ```text ```` block of boxes and arrows is a diagram nobody can read on a phone, search, +or keep in step. Convert it. A legend that sits under ASCII art ("pm-park: any time, capture only") +becomes a table or a list after the diagram, not part of it. + +## Worked conversion + +The PM skills README describes its loop as ASCII art: + +```text +pm-resume -> pm-next -> [design -> plan -> execute -> finish] + ^ | | | + | pm-checkpoint (stage updates) pm-checkpoint (done, on the branch) + | | + +-----------------------------------------+ + | + push branch -> one PR -> CI -> merge +``` + +**1. The model** — `docs/diagrams/the-loop.c4`: + +```likec4 +specification { + element skill { + style { + shape rectangle + } + } + element stage { + style { + shape rectangle + color secondary + } + } + element delivery { + style { + shape rectangle + color muted + } + } +} + +model { + resume = skill 'pm-resume' { + description 'Reads the planning files and recommends one action' + } + next = skill 'pm-next' { + description 'Claims the first item that is still the right one' + } + design = stage 'design' + plan = stage 'plan' + execute = stage 'execute' + finish = stage 'finish' + checkpoint = skill 'pm-checkpoint' { + description 'Writes where work stopped, so a compacted session can resume' + } + push = delivery 'push branch' + pr = delivery 'one PR' + ci = delivery 'CI' + merge = delivery 'merge' + + resume -> next + next -> design 'claims an item' + design -> plan + plan -> execute + execute -> finish + design -> checkpoint 'stage updates' + plan -> checkpoint 'stage updates' + execute -> checkpoint 'stage updates' + finish -> checkpoint 'done, on the branch' + checkpoint -> next 'next item' + checkpoint -> push + push -> pr + pr -> ci + ci -> merge +} + +views { + view theLoop { + title 'The loop' + include * + autoLayout LeftRight + } +} +``` + +**2. The README section**: + +````markdown +## The loop + + +```mermaid +flowchart LR + resume[pm-resume] --> next[pm-next] + next -- claims an item --> design + design --> plan --> execute --> finish + design & plan & execute -. stage updates .-> checkpoint[pm-checkpoint] + finish -- done, on the branch --> checkpoint + checkpoint -- next item --> next + checkpoint --> push[push branch] --> pr[one PR] --> ci[CI] --> merge +``` + +[LikeC4 model](./docs/diagrams/the-loop.c4) + + +| Skill | When it runs | +| -- | -- | +| `pm-park` | any time, capture only | +| `pm-review` | weekly | +| `pm-risk` | capture a risk any time; manage the register when no workflow is active | +```` + +The file path is relative to the page, so from the root `README.md` it is `./docs/diagrams/the-loop.c4`; +from `docs/wiki/guide/Loop.md` it would be `../../diagrams/the-loop.c4`. diff --git a/skills/purview-site-docs/references/layout.md b/skills/purview-site-docs/references/layout.md new file mode 100644 index 0000000..ba8a033 --- /dev/null +++ b/skills/purview-site-docs/references/layout.md @@ -0,0 +1,92 @@ +# Layout and what the site does to a page + +## Which files are read + +The catalogue entry's `docs.source` decides: + +| `docs.source` | Read | Use it when | +| -- | -- | -- | +| `github-path` | every `/**/*.md` at the channel's ref (default branch, or a release tag for a `live` channel) | **the default.** Any repository with more than one page. Supports folders, channels and redirects | +| `wiki` | the top-level `.md` files of the GitHub wiki (`.wiki.git`) | only for an existing GitHub wiki; it has no history the site can pin, and no folders | +| `readme` | `README.md` alone, as one page | a project small enough to be one page. Grows into `github-path` — a README with more than about eight `##` sections or a few hundred lines is a docs tree waiting to happen | + +Files that are never pages: `_Sidebar.md`, `_Footer.md`, `_redirects.yml`, `.c4` models, images, and +anything the entry lists in `docs.exclude` (matched on the base file name, `*` wildcards). + +## The landing page + +1. A conventional index at the docs root wins: `Home.md`, `index.md` or `README.md` (case-insensitive). +2. The entry's `docs.rootPage` may name another page only when the conventional one is excluded. +3. No index and no `rootPage` fails the sync: *"docs: no root page exists"*. + +Prefer `Home.md` as the hub (what the project is, then a table of sections linking every page) and a +`Getting-Started.md` beside it. + +## URL of every page + +The path from the docs root, each segment lower-cased with anything other than letters, digits and +dashes collapsed to `-`, and the `.md` dropped: + +| Source | Published at | +| -- | -- | +| `docs/wiki/Home.md` (root page) | `/docs//` | +| `docs/wiki/Getting-Started.md` | `/docs//getting-started/` | +| `docs/wiki/guide/README.md` | `/docs//guide/` | +| `docs/wiki/guide/First-Project.md` | `/docs//guide/first-project/` | +| `docs/wiki/Q&A/Why Not.md` | `/docs//q-a/why-not/` (works, but rename it) | +| `docs/wiki/___/Page.md` | **sync fails**: the folder reduces to nothing | + +Two files that reduce to the same URL collide; keep names distinct after lower-casing. A page may not +be named `prerelease` (that segment is reserved for the prerelease documentation channel). + +## Sidebar and order + +- Folders become collapsible sidebar groups labelled from the folder name (`getting-started` → + "Getting Started"), placed where their first page appears. +- Order: the entry's `docs.order` (explicit slugs) → the link order in `_Sidebar.md` → alphabetical. +- `_Sidebar.md` links pages by their path from the docs root: + +```markdown +- [Home](Home.md) +- **Guide** + - [Installation](guide/Installation.md) + - [First project](guide/First-Project.md) +- **Configuration** + - [Options](configuration/Options.md) +``` + +## What the site rewrites + +| In the source | On the site | +| -- | -- | +| `[Options](../configuration/Options.md#timeouts)` | a link to `/docs//configuration/options/#timeouts`; an anchor that is not a heading on the target page is dropped | +| `[Code](../../src/Thing.cs)` (outside the docs folder) | `https://github.com//blob//src/Thing.cs` | +| `![Flow](./images/flow.png)` | `https://github.com//raw///images/flow.png` | +| `![badge](https://img.shields.io/…)` | unchanged | +| `> [!WARNING]` blockquote | a Starlight caution aside | +| the first `# Title` | the page header (title, sidebar label); removed from the body so it shows once | +| a second `# Heading` | demoted to `##` — one title per page | +| a `purview:likec4` block | the LikeC4 view (see `diagrams.md`) | +| a bare ```` ```mermaid ```` fence | a Mermaid diagram (both sides render it) | +| a bare ```` ```likec4 ```` fence | a LikeC4 diagram on the site — but **source code on GitHub**; use a pair | +| mkdocs/Material markup (`!!! note`, `{ .md-button }`, `{% … %}`) | stripped or converted; do not write it | + +Front matter in a source page is not needed: the site writes its own (title, description, edit link, +last-reviewed date from the last commit, the repository's topics as tags). A page not touched for 365 +days is flagged as stale on the site. + +## Documentation channels + +A project whose default branch runs ahead of its last release can publish two versions of its docs: +`live` (pinned to the latest stable tag, at `/docs//`) and `prerelease` (the default branch, at +`/docs//prerelease/`). This is declared in the catalogue entry, not in the repository. What it means +for you: a page reorganisation is visible on the prerelease channel immediately and on live only after +the release that carries it, and `_redirects.yml` is read per channel, so each channel forwards its own +old URLs at the right moment. + +## Writing style that survives both renderers + +- ATX headings (`##`), sentence or title case consistently, no trailing punctuation. +- Tables for anything enumerable. +- Wrap prose near 100–110 columns, so documentation diffs stay reviewable. +- HTML only where Markdown cannot express it; the site renders it, but GitHub sanitises it. diff --git a/skills/purview-site-docs/references/redirects.md b/skills/purview-site-docs/references/redirects.md new file mode 100644 index 0000000..7658587 --- /dev/null +++ b/skills/purview-site-docs/references/redirects.md @@ -0,0 +1,49 @@ +# `_redirects.yml` — keeping moved pages reachable + +A page's path is its URL. Moving `Getting-Started.md` into `guide/` moves +`/docs//getting-started/` to `/docs//guide/getting-started/`, and every bookmark, search result +and link from another repository to the old address would 404. `_redirects.yml` records the move in the +same commit as the move. + +## Where and what + +`/_redirects.yml` — beside `Home.md` (for a GitHub wiki, at the wiki root). It is never a page. + +```yaml +# previous: current +# Either side may be a slug (`guide/getting-started`) or a Markdown path from the +# docs root (`guide/Getting-Started.md`); both are reduced to the published slug. +redirects: + getting-started: guide/getting-started + Custom-Rules.md: schemas/Custom-Rules.md + old-landing: index # `index` is the docs root, /docs// +``` + +A bare map without the `redirects:` key is accepted too. Keep to this shape: one `previous: current` +pair per line, comments with `#`, quotes only when a name needs them. + +## How the site uses it + +1. The sync reads the file at each documentation channel's ref — so a reorganisation on the default + branch forwards the prerelease channel's URLs straight away, and the live channel's only once the + release that contains it is published. +2. For each pair it writes a small redirect page at the old address pointing to the new one + (`noindex`, not searchable, not in the sitemap). +3. A redirect is **written only when its destination is a published page and its source is not.** A + real page always wins over a redirect. +4. A pair that points nowhere, or whose source still exists, is reported in the sync log and skipped. +5. A file that is not valid YAML, is not a map, or holds a pair that cannot become a URL, **fails the + site's sync** naming the file — the moves it was meant to protect would otherwise vanish silently. + +The site's catalogue entry may also carry `docs.redirects` for the same purpose; when both name the +same old slug the catalogue entry wins. Prefer the file: the move and its redirect then live in one +commit, in the repository that made them. + +## Rules + +- Add the pair in the commit that moves the page. Never remove a released pair. +- Point at the final address. When a page moves twice, update the first pair's destination rather than + chaining `a → b → c`. +- Redirect pages only — not anchors, not folders. A folder with a `README.md` is a page (`guide`), so + it can be a destination. +- After the move, fix inbound links anyway: a redirect is for readers outside the repository. diff --git a/skills/purview-site-integration/SKILL.md b/skills/purview-site-integration/SKILL.md new file mode 100644 index 0000000..24cd40a --- /dev/null +++ b/skills/purview-site-integration/SKILL.md @@ -0,0 +1,89 @@ +--- +name: purview-site-integration +description: Make a Purview repository (a NuGet library, an MSBuild SDK, an npm package, or a pure agent-skills repository such as pm-skills) fully integrated with purview.dev - what the site reads from the repository and when, the GitHub metadata it shows (topics as tags, description, releases, default branch), the documentation contract, published agent skills, mirrored assets, and the catalogue entry the site maintainers write for it. Use when preparing a repository to join purview.dev, when its project page or docs look wrong or stale, when adding skills, packages or releases a reader should see, or when asked "how does this show up on purview.dev?". +license: MIT +metadata: + site: https://purview.dev + repository: purview-dev/purview-dev +--- + +# Integrating a repository with purview.dev + +purview.dev is a static site built from two inputs: + +1. **The catalogue** — one YAML file per project in `purview-dev/purview-dev` + (`src/src/data/projects/.yml`): the curated words (description, origin, when to use it, use + cases) and the pointers (which docs to read, which packages and skills to show). +2. **This repository** — read at build time through the GitHub API, raw file URLs and the package + registries. Nothing is pushed to the site; a scheduled build and every site deploy pull it. + +So integration has two halves: make this repository **readable** (this skill and +`purview-site-docs`), and give the site maintainers what they need to **write the entry** +([references/catalogue-contract.md](references/catalogue-contract.md)). Do not edit +`purview-dev/purview-dev` from this repository unless the user asks; propose the entry instead. + +## What the site reads from this repository + +| Site surface | Read from | Your responsibility | +| -- | -- | -- | +| Tag chips on cards, search, docs pages | GitHub **topics** | 6–15 lower-case, hyphenated topics (`csharp`, `source-generators`, `opentelemetry`). The first four are what a card shows. There is no other way to set tags | +| "From the repository" note on the project page | GitHub **description** | One sentence that agrees with the catalogue's `shortDescription` | +| Versions, release dates, "latest release" | GitHub **releases** + the NuGet / npm registry | Tag releases `vX.Y.Z`; prereleases `vX.Y.Z-prerelease.N`. Publish packages to the registry the entry names | +| Status checks (`stable`/`preview`/`archived`) | releases, registry versions, the archived flag | A stable registry version means `stable`; prerelease-only means `preview`; archive the repository rather than leave it idle | +| Documentation at `/docs//` | `docs/wiki/**/*.md` (or `docs/`, a wiki, or `README.md`) at the **default branch** (or a release tag) | Follow `purview-site-docs`: hierarchical folders, one H1, relative links, diagram pairs, `_redirects.yml` | +| Agent skills listed on the project page, served at `/skills///` | every `skills//SKILL.md` (discovered; the entry can opt out) and everything beside it | Front matter `name` (equal to the folder) and `description`. Skills reach users through `npx skills add purview-dev/`, never inside a package | +| Mirrored assets (JSON schemas and similar) | repository paths the entry lists under `assets:` | Keep the path stable; moving it breaks the site build until the entry follows | +| Build badge | the repository's GitHub Actions | A workflow that runs on the default branch | +| Discussions link | repository settings | Enable Discussions if you want readers to ask there | +| Star count | GitHub | — | + +The default branch must be `main` unless the entry says otherwise; the docs, the edit links and the +"last reviewed" dates all follow it. + +## The integration checklist + +1. **Metadata** — description set, 6–15 topics, the repository public, Discussions decided: + + ```shell + gh api repos/purview-dev/ --jq '{description, topics, archived, default_branch, has_discussions}' + ``` + +2. **Releases** — at least one GitHub release whose tag matches the packages' version, published to + NuGet or npm. A project with no release shows no version anywhere. +3. **Documentation** — a `docs/wiki/` tree (or a deliberate one-page `README.md`), laid out per + `purview-site-docs`. ASCII-art diagrams converted to LikeC4/Mermaid pairs. A `_redirects.yml` the + moment a page moves. +4. **Skills** — one folder per skill under `skills/`, each with a `SKILL.md` whose front matter `name` + matches the folder and whose `description` says what it does and when to use it. +5. **README** — the GitHub front door: what it is, install, a minimal example, a link into the docs. + It points into the docs rather than duplicating them (a `readme`-sourced project is the exception: + the README *is* the docs). +6. **The entry** — collect what [references/catalogue-contract.md](references/catalogue-contract.md) + asks for, and hand it to the site maintainers (an issue or pull request on + `purview-dev/purview-dev`). The site's own agents onboard projects from that information. +7. **Assess** — run the `purview-site-assessment` skill and fix every error before asking for the + entry. + +## Live examples and diagrams on the project page + +A project page can link a live example: an `.mdx` page in the site repository +(`src/src/pages/examples/.mdx`). That is authored on the site side; propose it with the entry when +the project has something visual to show. Diagrams inside your docs need nothing from the site — use +the diagram pairs from `purview-site-docs`. + +## When something looks wrong on the site + +| Symptom | Usual cause | +| -- | -- | +| No tags on the card | no GitHub topics | +| Version missing or old | no release, a tag that does not match the package version, or the registry has not indexed it yet | +| A docs page missing | not under the docs path, excluded by name, or a slug that collides with another | +| A link on a docs page goes to GitHub | it points outside the docs folder, or at a page that does not exist | +| A diagram shows Mermaid on the site | the `purview:likec4` model file or view was not found — the site's sync log says which | +| A bookmarked docs URL 404s | a page moved without a `_redirects.yml` line | +| Skills not listed | the skill is not at `skills//SKILL.md`, its front matter is missing a field, or the entry opts out with `skills: false` | + +## Related skills + +- `purview-site-docs` — how to write and organise the documentation itself. +- `purview-site-assessment` — check this repository against both skills and get a prioritised report. diff --git a/skills/purview-site-integration/references/catalogue-contract.md b/skills/purview-site-integration/references/catalogue-contract.md new file mode 100644 index 0000000..d945fa4 --- /dev/null +++ b/skills/purview-site-integration/references/catalogue-contract.md @@ -0,0 +1,93 @@ +# The catalogue entry — what to hand the site maintainers + +The entry lives in `purview-dev/purview-dev` at `src/src/data/projects/.yml` and is validated by the +site's schema, so its vocabulary is fixed. Collect the answers below from this repository and propose +them; the maintainers (or the site's onboarding agent) turn them into the entry. + +## Identity + +| Field | What to provide | +| -- | -- | +| `id` | lower-case slug, normally the repository name without `purview-` (`unions`, `build-sdk`). It is the URL: `/projects//`, `/docs//` | +| `name` | display name; must slugify to `id` when the project has docs (`Build SDK` → `build-sdk`). Not prefixed with "Purview" | +| `repository` | `purview-dev/` | +| `category` | one of `application-framework`, `validation`, `observability`, `source-generation`, `aspire`, `build-tooling`, `developer-tooling` | +| `status` | `stable` (a stable release is published), `preview` (prereleases only), `archived` | +| `experimental` | `true` when the API and packaging may change without notice | + +## The words + +Write these for a reader deciding whether to use the project. Backticks become inline code on the site. + +| Field | Shape | +| -- | -- | +| `shortDescription` | one sentence; the card text | +| `description` | 2–4 sentences: the problem and what the project does about it | +| `origin` | why it became a reusable project rather than code in one application | +| `useWhen` / `avoidWhen` | the honest fit, including when something simpler is better | +| `useCases` | at least one, each: `audience` (`developer`, `team-lead`, `architect`, `contributor`), `title`, `scenario`, optional `code` + `language`, `outcome`, optional `evidence`, optional `docsPage` | +| `acknowledgments` | upstream work the project builds on: `name`, `url`, `description` | +| `related` | ids of related Purview projects | + +A use case is concrete: a real situation, real code, a measurable outcome, and evidence that can be +checked (a test, a benchmark, a diagnostic id). `docsPage` is a **single-segment** slug of a docs page +at the docs root — so a page used as a deep link stays at the root of the tree. + +## Pointers + +| Field | What to provide | +| -- | -- | +| `docs.source` | `github-path` (preferred), `wiki`, or `readme` | +| `docs.path` | the docs folder, e.g. `docs/wiki` | +| `docs.rootPage` | the landing page, e.g. `Home.md` or `Getting-Started.md` | +| `docs.exclude` | base file names that are not pages, e.g. `_Sidebar.md` | +| `docs.channels` | only when `main` runs ahead of the last release: `live: { ref: latest-stable }`, `prerelease: { ref: default }` | +| `packages` | each package id, its `registry` (`nuget` default, or `npm`), a one-line `description`, and which is `primary` | +| `install` | for NuGet: `nuget` (a `PackageReference`), `msbuild-sdk`, or `dotnet-tool` | +| `targetFrameworks` | what the packages actually ship (`net8.0`, `net10.0`, `netstandard2.0`) — the site checks them against NuGet | +| `skills` | usually omitted: every `skills/*/SKILL.md` is discovered and published. `false` opts out; a list of `{ name, path?, description? }` overrides discovery | +| `assets` | `{ path, output, description? }` for files the site should serve, e.g. a JSON schema at `/schemas/.json` | + +Redirects for moved pages belong in this repository's `_redirects.yml`, not in the entry. + +## A template to fill in + +```yaml +id: +name: +shortDescription: +description: >- + <2–4 sentences> +origin: >- + +useWhen: >- + +avoidWhen: >- + +repository: purview-dev/ +category: +status: +docs: + source: github-path + path: docs/wiki + rootPage: Home.md + exclude: + - _Sidebar.md +packages: + - id: + description: + primary: true +useCases: + - audience: developer + title: + scenario: >- + + code: | + + language: csharp + outcome: >- + + evidence: >- + + docsPage: +``` From a778d104db40930a08e355ec9c19ec806d816b20 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 14:01:52 +0000 Subject: [PATCH 6/7] ci: rebuild the site when catalogue projects change upstream --- .github/workflows/deploy.yml | 23 +++- .github/workflows/upstream-refresh.yml | 85 ++++++++++++++ AGENTS.md | 1 + README.md | 19 ++- docs/decisions/0014-upstream-refresh.md | 44 +++++++ justfile | 4 + package.json | 3 +- src/package.json | 3 +- src/scripts/upstream-state.ts | 150 ++++++++++++++++++++++++ src/src/lib/upstream.ts | 112 ++++++++++++++++++ src/tests/unit/upstream.test.ts | 73 ++++++++++++ 11 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/upstream-refresh.yml create mode 100644 docs/decisions/0014-upstream-refresh.md create mode 100644 src/scripts/upstream-state.ts create mode 100644 src/src/lib/upstream.ts create mode 100644 src/tests/unit/upstream.test.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d9ca864..c91f5a6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,13 +12,20 @@ on: required: false type: string default: "" + # Called by upstream-refresh.yml when a catalogue project's repository or + # packages changed since the deployed build (ADR 0014). That workflow also + # owns the `purview-site-rebuild` repository dispatch, so a release elsewhere + # waits for its packages to publish before this runs. + workflow_call: + inputs: + pages-base: + required: false + type: string + default: "" schedule: - # Weekly refresh of release information from GitHub and NuGet. + # Weekly refresh of release information from GitHub and NuGet (download + # counts and other figures the nightly upstream check does not compare). - cron: "17 4 * * 1" - repository_dispatch: - # Fired by a release workflow in another purview-dev repository. The - # expected payload contract is documented in the README. - types: [purview-site-rebuild] permissions: contents: read @@ -145,6 +152,12 @@ jobs: mkdir -p src/dist/_discovery cp .discovery-capture/previous.json src/dist/_discovery/previous.json + - name: Record the upstream state this build was made from + # Published as /_discovery/upstream.json. The nightly upstream check + # compares the catalogue repositories and packages with it and skips the + # rebuild when nothing moved (ADR 0014). + run: bun run upstream:state --write dist/_discovery/upstream.json + - name: Configure Pages # actions/configure-pages v6.0.0 uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d diff --git a/.github/workflows/upstream-refresh.yml b/.github/workflows/upstream-refresh.yml new file mode 100644 index 0000000..90d87ed --- /dev/null +++ b/.github/workflows/upstream-refresh.yml @@ -0,0 +1,85 @@ +name: Refresh from upstream projects + +# Rebuilds and redeploys the site when a catalogue project changed upstream — +# a commit on its default branch, a new tag, or a new package version — and +# not otherwise (ADR 0014). The catalogue projects are only read: nothing here +# needs a workflow, secret or setting in their repositories. +# +# - Nightly, the upstream state is compared with the state the deployed site +# recorded at build time (/_discovery/upstream.json). +# - A `purview-site-rebuild` repository dispatch (sent by a release workflow +# elsewhere, see the README) starts the same check after a 15-minute settle +# period, so the packages that release published are indexed before the build +# reads them. A newer dispatch cancels a run that is still settling, so a burst +# of releases produces one build. + +on: + schedule: + - cron: "23 2 * * *" + repository_dispatch: + types: [purview-site-rebuild] + workflow_dispatch: + inputs: + force: + description: Rebuild even when nothing changed upstream. + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + settle: + runs-on: ubuntu-latest + # Only one run may wait at a time; a newer trigger cancels the waiting one + # (and with it that run's check and build), so the newest event wins. + concurrency: + group: upstream-refresh-settle + cancel-in-progress: true + steps: + - name: Wait for upstream publishing to settle + if: github.event_name == 'repository_dispatch' + env: + SOURCE: ${{ github.event.client_payload.repository }} + VERSION: ${{ github.event.client_payload.version }} + run: | + echo "Rebuild requested by ${SOURCE:-an upstream repository} ${VERSION}; waiting 15 minutes for package publishing." + sleep 900 + + check: + needs: settle + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.compare.outputs.changed }} + steps: + - name: Checkout + # actions/checkout v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Install Bun + # oven-sh/setup-bun v2.2.0 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.4.4 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Compare upstream with the deployed build + id: compare + env: + SITE_URL: ${{ vars.SITE_URL }} + run: | + ORIGIN="${SITE_URL:-https://purview.dev}" + bun run upstream:state --compare "$ORIGIN/_discovery/upstream.json" + + deploy: + needs: check + if: needs.check.outputs.changed == 'true' || inputs.force == true + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/deploy.yml + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 50b160a..657ba38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,4 +184,5 @@ src/src/data/projects/.yml` against a clone of its repository. | Pages | `src/src/pages/{index,about,use-cases,docs,projects,releases}/` | | Live examples | `src/src/pages/examples/.mdx` (linked from the project page automatically) | | Validation | `justfile`, `src/scripts/{check-links,check-generated,check-assets,check-projects}.ts` | +| Upstream-triggered rebuilds | `.github/workflows/upstream-refresh.yml`, `src/scripts/upstream-state.ts`, `src/src/lib/upstream.ts` (ADR 0014) | | Architecture decisions | `docs/decisions/*.md` | diff --git a/README.md b/README.md index 5ba6195..2854260 100644 --- a/README.md +++ b/README.md @@ -568,15 +568,26 @@ granting that repository any secrets. In that repository's release workflow, call: ```shell -gh api repos/purview-dev/purview-dev.github.io/dispatches \ +gh api repos/purview-dev/purview-dev/dispatches \ -f event_type=purview-site-rebuild \ -f "client_payload[repository]=purview-dev/" \ -f "client_payload[version]=" ``` -The deploy workflow listens for `repository_dispatch` with type -`purview-site-rebuild`. The payload is informational and is not required for -the rebuild to succeed; the build always fetches fresh data itself. +`upstream-refresh.yml` listens for `repository_dispatch` with type +`purview-site-rebuild`. It waits 15 minutes so the release's packages finish +publishing (a newer dispatch cancels a run that is still waiting, so a burst of +releases builds once), then rebuilds only if something changed upstream. The +payload is informational; the build always fetches fresh data itself. + +### Nightly upstream refresh + +No product repository needs the dispatch. Every night `upstream-refresh.yml` +compares each catalogue project's default-branch head, tags and package versions +with the state the deployed site recorded at build time +(`/_discovery/upstream.json`), and calls `deploy.yml` only when something moved +(ADR 0014). Run it by hand from the Actions tab (`force` rebuilds regardless), or +locally with `just upstream-check`. The release workflow is intentionally scoped to root `package.json` changes so content refreshes do not get blocked by an unchanged version or an existing diff --git a/docs/decisions/0014-upstream-refresh.md b/docs/decisions/0014-upstream-refresh.md new file mode 100644 index 0000000..4aa0a2f --- /dev/null +++ b/docs/decisions/0014-upstream-refresh.md @@ -0,0 +1,44 @@ +# 0014 — Rebuild when an upstream project changes, and only then + +- **Status:** Accepted +- **Date:** 2026-11-14 +- **Supersedes:** none + +## Context + +The site is built from the catalogue projects' repositories (documentation, skills, assets on the +default branch), their release tags and their package versions. It was rebuilt on a push here, weekly, +and on a `purview-site-rebuild` repository dispatch — which only a release workflow in a product +repository sends. A documentation change merged to a product repository's `main` therefore waited up to +a week, and a dispatch fired at release time often built before NuGet had indexed the new package. + +A rebuild triggered directly by a push to another repository's `main` is not possible from here: GitHub +delivers no cross-repository push event, so each product repository would need a workflow (and a token) +of its own. The requirement is that the catalogue projects are not changed for this. + +## Decision + +1. **The deployed site records what it was built from.** After the build, `scripts/upstream-state.ts + --write` publishes `/_discovery/upstream.json`: for each non-archived catalogue project, the head of + its default branch and a digest of its tags (both from `git ls-remote`, which needs no token and has + no API rate limit), and the latest version of each package from its registry. +2. **A nightly check compares, and rebuilds only on a change.** `upstream-refresh.yml` runs + `--compare` against the deployed file and calls `deploy.yml` (now also a reusable workflow) only + when something moved: a commit, a tag, a package version, or a project added to or removed from the + catalogue. A value that could not be read on either side is not a change, so a registry outage does + not rebuild every night. `workflow_dispatch` with `force` rebuilds regardless. +3. **The repository dispatch settles first.** `purview-site-rebuild` moved from `deploy.yml` to the + same workflow: it waits 15 minutes for package publishing, then runs the same check. The waiting job + is in a `cancel-in-progress` concurrency group, so a newer dispatch (or the nightly run) cancels a run + that is still waiting and a burst of releases produces one build. A build already past the wait is + never cancelled; `deploy.yml` keeps its own non-cancelling `pages` group. +4. **The weekly unconditional deploy stays**, for the figures the check deliberately ignores (download + counts, stars). + +## Consequences + +- An upstream documentation change reaches the site the next night without any change upstream. A + product repository that wants it sooner can send the dispatch from its own workflow, as before. +- The first nightly run after this lands rebuilds once (there is no recorded state yet); every deploy + from then on records it. +- `/_discovery/upstream.json` is public. It holds only public data: commit ids, tag digests, versions. diff --git a/justfile b/justfile index dc1e5ef..c802c3f 100644 --- a/justfile +++ b/justfile @@ -95,6 +95,10 @@ fix-projects *ARGS: discovery-validate: bun run discovery:validate +# Compare the upstream repositories and packages with what the deployed site was built from (ADR 0014). +upstream-check: + bun run upstream:state --compare https://purview.dev/_discovery/upstream.json + # Regenerate discovery artifacts in dist/ from the current caches (no full build). discovery-manifest: bun run discovery:manifest diff --git a/package.json b/package.json index c0ad2d8..1d70c1b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "refresh:fixtures": "bun run --cwd src refresh:fixtures", "test": "bun run --cwd src test", "test:dist": "bun run --cwd src test:dist", - "typecheck": "bun run --cwd src typecheck" + "typecheck": "bun run --cwd src typecheck", + "upstream:state": "bun run --cwd src upstream:state" }, "engines": { "bun": ">=1.4.4", diff --git a/src/package.json b/src/package.json index 63d7507..d7887f6 100644 --- a/src/package.json +++ b/src/package.json @@ -28,7 +28,8 @@ "refresh:fixtures": "bun run scripts/refresh-fixtures.ts", "test": "bun test tests/unit", "test:dist": "bun test tests/dist", - "typecheck": "astro check" + "typecheck": "astro check", + "upstream:state": "bun run scripts/upstream-state.ts" }, "dependencies": { "@astrojs/markdown-satteri": "0.4.9", diff --git a/src/scripts/upstream-state.ts b/src/scripts/upstream-state.ts new file mode 100644 index 0000000..131d42e --- /dev/null +++ b/src/scripts/upstream-state.ts @@ -0,0 +1,150 @@ +import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import { loadProjects } from '../src/lib/manifest/load'; +import { + parseLsRemote, + readUpstreamState, + UPSTREAM_STATE_SCHEMA, + upstreamChanges, + type UpstreamProjectState, + type UpstreamState, +} from '../src/lib/upstream'; + +/** + * Record or compare the upstream state the site is built from (ADR 0014). + * + * bun run scripts/upstream-state.ts --write dist/_discovery/upstream.json + * bun run scripts/upstream-state.ts --compare https://purview.dev/_discovery/upstream.json + * + * `--write` runs in the deploy workflow, after the build, so the deployed site + * carries the state it was built from. `--compare` runs in the scheduled + * refresh: it prints what moved and sets `changed=true|false` in + * `$GITHUB_OUTPUT`. Repository refs are read with `git ls-remote`, which needs + * no API token and no rate limit; versions come from the registries' public + * indexes. The catalogue projects themselves are only read, never changed. + */ + +function git(args: string[]): string | null { + const result = Bun.spawnSync(['git', ...args], { stderr: 'pipe' }); + return result.exitCode === 0 ? result.stdout.toString() : null; +} + +async function json(url: string): Promise { + try { + const response = await fetch(url, { headers: { 'User-Agent': 'purview-dev-website' } }); + return response.ok ? await response.json() : null; + } catch { + return null; + } +} + +async function latestVersion(registry: string, id: string): Promise { + if (registry === 'npm') { + const document = (await json(`https://registry.npmjs.org/${id}`)) as { + 'dist-tags'?: Record; + } | null; + const tags = document?.['dist-tags']; + return tags ? JSON.stringify(Object.entries(tags).toSorted()) : null; + } + const index = (await json( + `https://api.nuget.org/v3-flatcontainer/${id.toLowerCase()}/index.json`, + )) as { versions?: string[] } | null; + return index?.versions?.at(-1) ?? null; +} + +async function projectState( + repository: string, + packages: { id: string; registry: string }[], +): Promise { + const url = `https://github.com/${repository}.git`; + const head = git(['ls-remote', url, 'HEAD']); + const tags = git(['ls-remote', '--tags', '--refs', url]); + const tagDigest = + tags === null + ? null + : new Bun.CryptoHasher('sha256') + .update( + [...parseLsRemote(tags)] + .map(([ref, sha]) => `${ref} ${sha}`) + .toSorted() + .join('\n'), + ) + .digest('hex') + .slice(0, 16); + const versions: Record = {}; + for (const pkg of packages) { + versions[`${pkg.registry}:${pkg.id}`] = await latestVersion(pkg.registry, pkg.id); + } + return { + repository, + head: head === null ? null : (parseLsRemote(head).get('HEAD') ?? null), + tags: tagDigest, + packages: versions, + }; +} + +async function currentState(): Promise { + const projects: UpstreamState['projects'] = {}; + for (const project of loadProjects()) { + if (project.status === 'archived') { + continue; + } + projects[project.id] = await projectState(project.repository, project.packages); + const state = projects[project.id]; + const unknown = [ + state?.head === null ? 'head' : null, + state?.tags === null ? 'tags' : null, + ...Object.entries(state?.packages ?? {}) + .filter(([, version]) => version === null) + .map(([pkg]) => pkg), + ].filter(Boolean); + if (unknown.length > 0) { + console.warn(` ${project.id}: could not read ${unknown.join(', ')}`); + } + } + return { schema: UPSTREAM_STATE_SCHEMA, generatedAt: new Date().toISOString(), projects }; +} + +function setOutput(name: string, value: string): void { + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + } +} + +async function run(): Promise { + const args = process.argv.slice(2); + const writeTo = args[args.indexOf('--write') + 1]; + const compareWith = args[args.indexOf('--compare') + 1]; + + if (args.includes('--write') && writeTo) { + const state = await currentState(); + const target = resolve(writeTo); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + console.log( + `Upstream state for ${Object.keys(state.projects).length} project(s) written to ${writeTo}.`, + ); + return 0; + } + + if (args.includes('--compare') && compareWith) { + const previous = readUpstreamState(await json(compareWith)); + const changes = upstreamChanges(previous, await currentState()); + if (changes.length === 0) { + console.log('Upstream unchanged since the deployed build; nothing to rebuild.'); + } else { + console.log(`Upstream changed (${changes.length}):`); + for (const change of changes) { + console.log(` ${change}`); + } + } + setOutput('changed', changes.length > 0 ? 'true' : 'false'); + return 0; + } + + console.error('Usage: upstream-state --write | --compare '); + return 2; +} + +process.exit(await run()); diff --git a/src/src/lib/upstream.ts b/src/src/lib/upstream.ts new file mode 100644 index 0000000..10b865e --- /dev/null +++ b/src/src/lib/upstream.ts @@ -0,0 +1,112 @@ +/** + * What the site was last built from, upstream (ADR 0014). + * + * The site is built from other repositories: their default branch (the + * documentation, skills and assets), their release tags and the versions their + * packages publish. A scheduled check compares those with the state the + * deployed site recorded at build time (`/_discovery/upstream.json`) and only + * rebuilds when something moved, so an idle night costs one small job instead + * of a full build and deployment. + */ + +export const UPSTREAM_STATE_SCHEMA = 1; + +/** Published beside the previous discovery manifest, outside Astro's route list. */ +export const UPSTREAM_STATE_PATH = '/_discovery/upstream.json'; + +export interface UpstreamProjectState { + repository: string; + /** Commit at the head of the default branch; null when it could not be read. */ + head: string | null; + /** Digest of every tag and the commit it points at; null when it could not be read. */ + tags: string | null; + /** Latest version per `registry:id`; null for a package whose registry could not be read. */ + packages: Record; +} + +export interface UpstreamState { + schema: typeof UPSTREAM_STATE_SCHEMA; + generatedAt: string; + projects: Record; +} + +/** Parse `git ls-remote` output into `ref → sha`. */ +export function parseLsRemote(output: string): Map { + const refs = new Map(); + for (const line of output.split('\n')) { + const [sha, ref] = line.trim().split(/\s+/); + if (sha && ref && /^[0-9a-f]{40}$/.test(sha)) { + refs.set(ref, sha); + } + } + return refs; +} + +/** Read an untrusted JSON document as an upstream state, or null when it is not one. */ +export function readUpstreamState(value: unknown): UpstreamState | null { + if ( + typeof value === 'object' && + value !== null && + (value as { schema?: unknown }).schema === UPSTREAM_STATE_SCHEMA && + typeof (value as { projects?: unknown }).projects === 'object' + ) { + return value as UpstreamState; + } + return null; +} + +/** True when both values are known and differ. An unreadable value is never a change. */ +function moved(previous: string | null | undefined, current: string | null | undefined): boolean { + return previous != null && current != null && previous !== current; +} + +/** + * The reasons the site should be rebuilt, one per change; empty when nothing moved. + * + * A project or package that appears or disappears is a change (the catalogue + * moved). A value that could not be read on either side is not: a registry + * outage must not turn into a rebuild every night, and a rebuild could not read + * it either. + */ +export function upstreamChanges(previous: UpstreamState | null, current: UpstreamState): string[] { + if (!previous) { + return ['no recorded upstream state for the deployed site']; + } + const changes: string[] = []; + for (const [id, now] of Object.entries(current.projects)) { + const before = previous.projects[id]; + if (!before) { + changes.push(`${id}: new in the catalogue`); + continue; + } + if (before.repository !== now.repository) { + changes.push(`${id}: repository ${before.repository} → ${now.repository}`); + } + if (moved(before.head, now.head)) { + changes.push( + `${id}: ${now.repository} default branch ${before.head?.slice(0, 7)} → ${now.head?.slice(0, 7)}`, + ); + } + if (moved(before.tags, now.tags)) { + changes.push(`${id}: ${now.repository} tags changed`); + } + for (const [pkg, version] of Object.entries(now.packages)) { + if (!(pkg in before.packages)) { + changes.push(`${id}: package ${pkg} added`); + } else if (moved(before.packages[pkg], version)) { + changes.push(`${id}: ${pkg} ${before.packages[pkg]} → ${version}`); + } + } + for (const pkg of Object.keys(before.packages)) { + if (!(pkg in now.packages)) { + changes.push(`${id}: package ${pkg} removed`); + } + } + } + for (const id of Object.keys(previous.projects)) { + if (!(id in current.projects)) { + changes.push(`${id}: removed from the catalogue`); + } + } + return changes; +} diff --git a/src/tests/unit/upstream.test.ts b/src/tests/unit/upstream.test.ts new file mode 100644 index 0000000..065f045 --- /dev/null +++ b/src/tests/unit/upstream.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; + +import { + parseLsRemote, + readUpstreamState, + upstreamChanges, + type UpstreamState, +} from '../../src/lib/upstream'; + +const SHA_A = 'a'.repeat(40); +const SHA_B = 'b'.repeat(40); + +function state(overrides: Partial = {}): UpstreamState { + return { + schema: 1, + generatedAt: '2026-11-14T00:00:00.000Z', + projects: { + unions: { + repository: 'purview-dev/unions', + head: SHA_A, + tags: 'tags-1', + packages: { 'nuget:Purview.Unions': '1.0.0-prerelease.3' }, + ...overrides, + }, + }, + }; +} + +describe('upstream state', () => { + test('parses git ls-remote output', () => { + expect(parseLsRemote(`${SHA_A}\tHEAD\n${SHA_B}\trefs/tags/v1.0.0\nnot a line\n`)).toEqual( + new Map([ + ['HEAD', SHA_A], + ['refs/tags/v1.0.0', SHA_B], + ]), + ); + }); + + test('reads only a document of the current schema', () => { + expect(readUpstreamState(state())).not.toBeNull(); + expect(readUpstreamState({ schema: 0, projects: {} })).toBeNull(); + expect(readUpstreamState(null)).toBeNull(); + }); + + test('nothing moved is no change', () => { + expect(upstreamChanges(state(), state())).toEqual([]); + }); + + test('a new commit, tag or package version is a change', () => { + expect(upstreamChanges(state(), state({ head: SHA_B }))).toEqual([ + 'unions: purview-dev/unions default branch aaaaaaa → bbbbbbb', + ]); + expect(upstreamChanges(state(), state({ tags: 'tags-2' }))).toHaveLength(1); + expect( + upstreamChanges(state(), state({ packages: { 'nuget:Purview.Unions': '1.0.0' } })), + ).toEqual(['unions: nuget:Purview.Unions 1.0.0-prerelease.3 → 1.0.0']); + }); + + test('a value that could not be read is never a change', () => { + expect(upstreamChanges(state(), state({ head: null, tags: null }))).toEqual([]); + expect(upstreamChanges(state(), state({ packages: { 'nuget:Purview.Unions': null } }))).toEqual( + [], + ); + }); + + test('a catalogue change, or no recorded state, is a change', () => { + const added = state(); + added.projects.results = { ...added.projects.unions!, repository: 'purview-dev/results' }; + expect(upstreamChanges(state(), added)).toEqual(['results: new in the catalogue']); + expect(upstreamChanges(added, state())).toEqual(['results: removed from the catalogue']); + expect(upstreamChanges(null, state())).toHaveLength(1); + }); +}); From c718313fddd948dfc3d95c8db1f9deab6b9a17f1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 14 Nov 2026 14:15:40 +0000 Subject: [PATCH 7/7] chore: bumped version number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1d70c1b..1711f00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-dev", - "version": "0.9.0", + "version": "0.10.0", "private": true, "description": "Purview-Dev public website and unified documentation portal (workspace root).", "license": "MIT",