From f84f636e04ec57b6e6d234ea32c1b535411efa24 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 05:29:49 +0900 Subject: [PATCH] Validate multilingual documentation contracts --- .github/workflows/ci.yaml | 24 +++ .gitignore | 1 + README.md | 8 + docs/.vitepress/config.ts | 43 +--- package.json | 7 +- scripts/docs-contract.mjs | 322 +++++++++++++++++++++++++++++ scripts/docs-contract.test.mjs | 193 +++++++++++++++++ scripts/docs-validation.config.mjs | 57 +++++ scripts/validate-docs-cli.mjs | 99 +++++++++ scripts/validate-docs-examples.mjs | 68 ++++++ scripts/validate-docs.mjs | 59 ++---- 11 files changed, 798 insertions(+), 83 deletions(-) create mode 100644 scripts/docs-contract.mjs create mode 100644 scripts/docs-contract.test.mjs create mode 100644 scripts/docs-validation.config.mjs create mode 100644 scripts/validate-docs-cli.mjs create mode 100644 scripts/validate-docs-examples.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36e6a12..498b42b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,25 @@ jobs: node-version: 22.14.0 cache: npm + - name: Read pinned CLI documentation contract + id: cli + run: | + node --input-type=module -e 'import { documentationContract } from "./scripts/docs-validation.config.mjs"; console.log(`revision=${documentationContract.cli.revision}`)' >> "$GITHUB_OUTPUT" + + - name: Check out pinned Stack CLI + uses: actions/checkout@v7 + with: + repository: stack-sh/cli + ref: ${{ steps.cli.outputs.revision }} + path: .stack-cli + persist-credentials: false + + - name: Install minimum supported Rust + run: rustup toolchain install 1.85.0 --profile minimal + + - name: Build pinned Stack CLI + run: cargo +1.85.0 build --manifest-path .stack-cli/Cargo.toml --locked + - name: Install dependencies run: npm ci @@ -36,6 +55,11 @@ jobs: - name: Run tests run: npm test + - name: Smoke test documentation CLI examples + env: + STACK_CLI_BIN: ${{ github.workspace }}/.stack-cli/target/debug/stack + run: npm run docs:smoke + - name: Build playground run: npm run build diff --git a/.gitignore b/.gitignore index db2955a..617ed6e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ docs/.vitepress/cache *.local .wrangler .dev.vars* +.stack-cli # Editor directories and files .vscode/* diff --git a/README.md b/README.md index fc4e7a6..674eafa 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ The WebAssembly adapter and editor language assets are installed from the exact ```sh npm run dev npm run docs:dev +npm run docs:check +npm run docs:test npm run format npm run format:check npm run lint @@ -38,6 +40,12 @@ npm run docs:preview Formatting and linting use Oxfmt and Oxlint. The production build compiles the React playground into `dist/`, validates the English, Japanese, Simplified Chinese, and Korean documentation sets, and builds VitePress with a `/docs/` base into `dist/docs/`. +## Documentation contract + +`npm run docs:check` validates that all four locales have the same page inventory, heading structure, and canonical code blocks. It also rejects broken internal links and anchors, unsafe external links, malformed code fences, and Stack examples that fail against the exact `@stack-sh/engine` version in `package.json`. `npm run docs:test` exercises the corresponding positive and negative fixtures. + +CI additionally builds the Stack CLI revision pinned in [`scripts/docs-validation.config.mjs`](./scripts/docs-validation.config.mjs) with its minimum supported Rust version, then runs `npm run docs:smoke` through `STACK_CLI_BIN`. This checks the documented version, help surface, and safe executable examples without adding Rust to normal Web builds. Intentional locale or execution differences must be declared in the same configuration with a non-empty reason; the validator rejects stale exceptions. + ## Brand mark [`public/favicon.svg`](./public/favicon.svg) is the canonical Stack logo mark for Web surfaces. The Playground uses it as its favicon and header mark; VitePress uses the identical [`docs/public/favicon.svg`](./docs/public/favicon.svg) copy for its favicon, navigation, and localized home pages. The documentation validator rejects the build if the two public-root copies differ. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c303e74..76a9a18 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -6,6 +6,8 @@ import { defineConfig } from "vitepress" import type { LanguageRegistration } from "@shikijs/core" import type { DefaultTheme } from "vitepress" +import { documentationContract } from "../../scripts/docs-validation.config.mjs" + const require = createRequire(import.meta.url) const stackGrammar = require("@stack-sh/language/grammar") as LanguageRegistration const siteOrigin = "https://stack-diagram.com" @@ -92,40 +94,13 @@ type Labels = { } function sidebar(prefix: string, labels: Labels): DefaultTheme.SidebarItem[] { - return [ - { - text: labels.guide, - items: [ - { text: labels.introduction, link: `${prefix}/guide/what-is-stack` }, - { text: labels.gettingStarted, link: `${prefix}/guide/getting-started` }, - { text: labels.playground, link: `${prefix}/guide/playground` }, - { text: labels.providerIcons, link: `${prefix}/guide/provider-icons` }, - ], - }, - { - text: labels.language, - items: [ - { text: labels.syntax, link: `${prefix}/language/syntax` }, - { text: labels.nodesAndGroups, link: `${prefix}/language/nodes-and-groups` }, - { text: labels.edgesAndLayout, link: `${prefix}/language/edges-and-layout` }, - { text: labels.themesAndIcons, link: `${prefix}/language/themes-and-icons` }, - { text: labels.formatting, link: `${prefix}/language/formatting` }, - ], - }, - { - text: labels.reference, - items: [ - { - text: labels.diagnosticsAndLimits, - link: `${prefix}/reference/diagnostics-and-limits`, - }, - { - text: labels.versioningAndSafety, - link: `${prefix}/reference/versioning-and-safety`, - }, - ], - }, - ] + return documentationContract.navigation.map((section) => ({ + text: labels[section.label as keyof Labels], + items: section.items.map((item) => ({ + text: labels[item.label as keyof Labels], + link: `${prefix}/${item.page.replace(/\.md$/, "")}`, + })), + })) } function localeTheme(prefix: string, labels: Labels): DefaultTheme.Config { diff --git a/package.json b/package.json index 501dcdd..5a453e3 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,17 @@ "cloudflare:check": "wrangler deploy --dry-run", "deploy": "npm run build && wrangler deploy", "dev": "vite", - "docs:build": "node scripts/validate-docs.mjs && vitepress build docs && node scripts/validate-docs-output.mjs", + "docs:build": "npm run docs:check && vitepress build docs && node scripts/validate-docs-output.mjs", + "docs:check": "node scripts/validate-docs.mjs && node scripts/validate-docs-examples.mjs", + "docs:smoke": "node scripts/validate-docs-cli.mjs", + "docs:test": "node --test scripts/*.test.mjs", "docs:dev": "vitepress dev docs", "docs:preview": "vitepress preview docs", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --deny-warnings .", "preview": "vite preview", - "test": "vitest run", + "test": "vitest run && npm run docs:test", "test:watch": "vitest" }, "dependencies": { diff --git a/scripts/docs-contract.mjs b/scripts/docs-contract.mjs new file mode 100644 index 0000000..5b4100f --- /dev/null +++ b/scripts/docs-contract.mjs @@ -0,0 +1,322 @@ +import { existsSync } from "node:fs" +import { readdir, readFile } from "node:fs/promises" +import path from "node:path" + +function relativePath(root, target) { + return path.relative(root, target).split(path.sep).join("/") +} + +async function markdownFiles(root, directory = root) { + const files = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === ".vitepress" || entry.name === "public") continue + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) files.push(...(await markdownFiles(root, absolute))) + if (entry.isFile() && entry.name.endsWith(".md")) files.push(relativePath(root, absolute)) + } + return files.sort() +} + +function exceptionKey(kind, exception) { + if (kind === "links") return `${kind}:${exception.page}:${exception.target}` + if (kind === "pageParity") return `${kind}:${exception.locale}:${exception.page}` + return `${kind}:${exception.locale}:${exception.page}` +} + +function prepareExceptions(exceptions) { + const configured = new Map() + for (const kind of ["pageParity", "headingParity", "codeBlockParity", "links"]) { + for (const exception of exceptions[kind] ?? []) { + if (typeof exception.reason !== "string" || exception.reason.trim().length === 0) { + throw new Error(`${kind} exception requires a non-empty reason`) + } + const key = exceptionKey(kind, exception) + if (configured.has(key)) throw new Error(`Duplicate documentation exception: ${key}`) + configured.set(key, exception) + } + } + return configured +} + +function consumeException(configured, used, kind, values) { + const key = exceptionKey(kind, values) + if (!configured.has(key)) return false + used.add(key) + return true +} + +function headingSlug(title) { + return title + .replace(/\s+\{#[A-Za-z0-9_-]+\}\s*$/, "") + .replace(/`([^`]*)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/<[^>]+>/g, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\p{Letter}\p{Number}\p{Mark}_-]/gu, "") +} + +export function parseMarkdown(page, source, allowedFenceLanguages) { + const headings = [] + const headingIds = new Set() + const slugCounts = new Map() + const codeBlocks = [] + const links = [] + let fence + + for (const [lineIndex, line] of source.split(/\r?\n/).entries()) { + const lineNumber = lineIndex + 1 + if (fence) { + if (/^ {0,3}```\s*$/.test(line)) { + const body = fence.lines.join("\n").trimEnd() + if (body.length === 0) throw new Error(`${page}:${fence.line} has an empty code block`) + codeBlocks.push({ language: fence.language, body, line: fence.line }) + fence = undefined + } else { + fence.lines.push(line) + } + continue + } + + const openingFence = line.match(/^ {0,3}```([A-Za-z0-9_-]+)\s*$/) + if (openingFence) { + const language = openingFence[1] + if (!allowedFenceLanguages.includes(language)) { + throw new Error(`${page}:${lineNumber} uses unsupported code fence '${language}'`) + } + fence = { language, line: lineNumber, lines: [] } + continue + } + if (/^ {0,3}```/.test(line)) { + throw new Error(`${page}:${lineNumber} has a malformed or unlabelled code fence`) + } + + const heading = line.match(/^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/) + if (heading) { + const explicitId = heading[2].match(/\s+\{#([A-Za-z0-9_-]+)\}\s*$/)?.[1] + const baseId = explicitId ?? headingSlug(heading[2]) + const duplicateIndex = slugCounts.get(baseId) ?? 0 + const id = duplicateIndex === 0 ? baseId : `${baseId}-${duplicateIndex}` + slugCounts.set(baseId, duplicateIndex + 1) + headings.push(heading[1].length) + headingIds.add(id) + } + + let parsedLinkCount = 0 + for (const match of line.matchAll(/!?\[[^\]]*\]\((<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\)/g)) { + parsedLinkCount += 1 + links.push({ target: match[1].replace(/^<|>$/g, ""), line: lineNumber }) + } + const apparentLinkCount = line.match(/\]\(/g)?.length ?? 0 + if (parsedLinkCount !== apparentLinkCount) { + throw new Error(`${page}:${lineNumber} has malformed Markdown link syntax`) + } + } + + if (fence) throw new Error(`${page}:${fence.line} has an unclosed code fence`) + return { headings, headingIds, codeBlocks, links } +} + +export function shellCommands(page, codeBlock) { + const commands = [] + let pending = "" + + for (const [lineIndex, line] of codeBlock.body.split("\n").entries()) { + if (line.trim().length === 0) continue + if (pending.length === 0) { + if (!line.startsWith("$ ")) { + throw new Error( + `${page}:${codeBlock.line + lineIndex + 1} must start a shell example with '$ '`, + ) + } + pending = line.slice(2).trim() + } else { + if (!/^\s+/.test(line)) { + throw new Error(`${page}:${codeBlock.line + lineIndex + 1} has an invalid continuation`) + } + pending += ` ${line.trim()}` + } + + if (pending.endsWith("\\")) { + pending = pending.slice(0, -1).trimEnd() + continue + } + if (!pending.startsWith("stack ")) { + throw new Error(`${page}:${codeBlock.line + lineIndex + 1} is not a Stack CLI example`) + } + commands.push(pending) + pending = "" + } + + if (pending.length > 0) throw new Error(`${page}:${codeBlock.line} has an unclosed continuation`) + return commands +} + +function markdownCandidates(page, target) { + const pageDirectory = path.posix.dirname(page) + const joined = target.startsWith("/") + ? target.replace(/^\/docs\/?/, "").replace(/^\//, "") + : path.posix.join(pageDirectory, target) + const normalized = path.posix.normalize(joined) + if (path.posix.extname(normalized)) return [normalized] + return [normalized, `${normalized}.md`, path.posix.join(normalized, "index.md")] +} + +function validateLink({ docsRoot, documents, page, link, configuredExceptions, usedExceptions }) { + const [rawPath, rawAnchor] = link.target.split("#", 2) + if (/^[A-Za-z][A-Za-z+.-]*:/.test(rawPath)) { + let url + try { + url = new URL(link.target) + } catch { + throw new Error(`${page}:${link.line} has an invalid external link: ${link.target}`) + } + if (url.protocol !== "https:" || url.username || url.password) { + throw new Error(`${page}:${link.line} must use a credential-free HTTPS link: ${link.target}`) + } + return + } + + let decodedPath + let decodedAnchor + try { + decodedPath = decodeURIComponent(rawPath) + decodedAnchor = rawAnchor === undefined ? undefined : decodeURIComponent(rawAnchor) + } catch { + throw new Error(`${page}:${link.line} has invalid percent encoding: ${link.target}`) + } + if (rawAnchor !== undefined && decodedAnchor.length === 0) { + throw new Error(`${page}:${link.line} has an empty anchor: ${link.target}`) + } + + const candidates = decodedPath.length === 0 ? [page] : markdownCandidates(page, decodedPath) + const destination = candidates.find((candidate) => documents.has(candidate)) + if (!destination) { + const resolvedDocsRoot = path.resolve(docsRoot) + const resolvedPublicRoot = path.resolve(docsRoot, "public") + const isInside = (root, candidate) => + candidate === root || candidate.startsWith(`${root}${path.sep}`) + const assetExists = candidates.some((candidate) => { + const absolute = path.resolve(docsRoot, candidate) + const publicAsset = path.resolve(docsRoot, "public", candidate.replace(/^\//, "")) + return ( + (isInside(resolvedDocsRoot, absolute) && existsSync(absolute)) || + (isInside(resolvedPublicRoot, publicAsset) && existsSync(publicAsset)) + ) + }) + if (assetExists) return + if ( + consumeException(configuredExceptions, usedExceptions, "links", { + page, + target: link.target, + }) + ) { + return + } + throw new Error(`${page}:${link.line} links to missing target ${link.target}`) + } + + if (decodedAnchor && !documents.get(destination).headingIds.has(decodedAnchor)) { + if ( + consumeException(configuredExceptions, usedExceptions, "links", { + page, + target: link.target, + }) + ) { + return + } + throw new Error(`${page}:${link.line} links to missing anchor ${link.target}`) + } +} + +export async function validateDocumentationContract({ + docsRoot, + locales, + allowedFenceLanguages, + navigationPages, + exceptions = {}, +}) { + const configuredExceptions = prepareExceptions(exceptions) + const usedExceptions = new Set() + const files = await markdownFiles(docsRoot) + const localePrefixes = locales.map((locale) => `${locale}/`) + const pages = files.filter((file) => !localePrefixes.some((prefix) => file.startsWith(prefix))) + if (!pages.includes("index.md")) throw new Error("English documentation is missing index.md") + const expectedNavigation = pages.filter((page) => page !== "index.md") + const actualNavigation = [...navigationPages].sort() + if (new Set(actualNavigation).size !== actualNavigation.length) { + throw new Error("Documentation navigation contains a duplicate page") + } + if (JSON.stringify(actualNavigation) !== JSON.stringify(expectedNavigation)) { + throw new Error("Documentation navigation does not match the English page inventory") + } + + const sources = new Map() + const documents = new Map() + for (const file of files) { + const source = await readFile(path.join(docsRoot, file), "utf8") + sources.set(file, source) + documents.set(file, parseMarkdown(file, source, allowedFenceLanguages)) + } + + for (const locale of locales) { + const translatedPages = files + .filter((file) => file.startsWith(`${locale}/`)) + .map((file) => file.slice(locale.length + 1)) + for (const page of new Set([...pages, ...translatedPages])) { + if (pages.includes(page) === translatedPages.includes(page)) continue + if (consumeException(configuredExceptions, usedExceptions, "pageParity", { locale, page })) { + continue + } + throw new Error(`${locale}/${page} does not match the English page inventory`) + } + + for (const page of pages.filter((candidate) => translatedPages.includes(candidate))) { + const english = documents.get(page) + const translated = documents.get(`${locale}/${page}`) + if (JSON.stringify(english.headings) !== JSON.stringify(translated.headings)) { + if ( + !consumeException(configuredExceptions, usedExceptions, "headingParity", { locale, page }) + ) { + throw new Error(`${locale}/${page} does not preserve the English heading structure`) + } + } + const englishCode = english.codeBlocks.map(({ language, body }) => ({ language, body })) + const translatedCode = translated.codeBlocks.map(({ language, body }) => ({ language, body })) + if (JSON.stringify(englishCode) !== JSON.stringify(translatedCode)) { + if ( + !consumeException(configuredExceptions, usedExceptions, "codeBlockParity", { + locale, + page, + }) + ) { + throw new Error(`${locale}/${page} does not preserve the canonical code examples`) + } + } + } + } + + for (const [page, document] of documents) { + for (const link of document.links) { + validateLink({ docsRoot, documents, page, link, configuredExceptions, usedExceptions }) + } + } + + const unusedExceptions = [...configuredExceptions.keys()].filter( + (key) => !usedExceptions.has(key), + ) + if (unusedExceptions.length > 0) { + throw new Error(`Unused documentation exceptions: ${unusedExceptions.join(", ")}`) + } + + const englishPages = new Map(pages.map((page) => [page, sources.get(page)])) + const cliExamples = [] + for (const page of pages) { + for (const codeBlock of documents.get(page).codeBlocks) { + if (codeBlock.language === "sh") cliExamples.push(...shellCommands(page, codeBlock)) + } + } + + return { pages, englishPages, documents, cliExamples } +} diff --git a/scripts/docs-contract.test.mjs b/scripts/docs-contract.test.mjs new file mode 100644 index 0000000..4648f2e --- /dev/null +++ b/scripts/docs-contract.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict" +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import test from "node:test" + +import { validateDocumentationContract } from "./docs-contract.mjs" + +const locales = ["ja", "zh", "ko"] +const allowedFenceLanguages = ["stack", "sh"] +const navigationPages = ["guide/page.md"] +const noExceptions = { + pageParity: [], + headingParity: [], + codeBlockParity: [], + links: [], +} +const home = `# Home + +[Guide details](./guide/page#details) +[Stack](https://stack-diagram.com/) + +\`\`\`sh +$ stack check architecture.stack +\`\`\` +` +const guide = `# Guide + +## Details + +\`\`\`stack +stack 1.0 + +diagram "Fixture" { + node api "API" +} +\`\`\` +` + +async function fixture() { + const docsRoot = await mkdtemp(path.join(os.tmpdir(), "stack-docs-contract-")) + const files = new Map([ + ["index.md", home], + ["guide/page.md", guide], + ]) + for (const locale of locales) { + files.set(`${locale}/index.md`, home) + files.set(`${locale}/guide/page.md`, guide) + } + for (const [file, source] of files) { + const destination = path.join(docsRoot, file) + await mkdir(path.dirname(destination), { recursive: true }) + await writeFile(destination, source) + } + return { + docsRoot, + write: async (file, source) => { + const destination = path.join(docsRoot, file) + await mkdir(path.dirname(destination), { recursive: true }) + await writeFile(destination, source) + }, + remove: (file) => rm(path.join(docsRoot, file)), + cleanup: () => rm(docsRoot, { recursive: true, force: true }), + } +} + +function validate(docsRoot, exceptions = noExceptions) { + return validateDocumentationContract({ + docsRoot, + locales, + allowedFenceLanguages, + navigationPages, + exceptions, + }) +} + +test("accepts matching locale pages, anchors, HTTPS links, and CLI examples", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + + const result = await validate(docs.docsRoot) + + assert.deepEqual(result.pages, ["guide/page.md", "index.md"]) + assert.deepEqual(result.cliExamples, ["stack check architecture.stack"]) +}) + +test("rejects a missing locale page", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + await docs.remove("ja/guide/page.md") + + await assert.rejects(validate(docs.docsRoot), /ja\/guide\/page\.md does not match/) +}) + +test("rejects pages missing from navigation", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + + await assert.rejects( + validateDocumentationContract({ + docsRoot: docs.docsRoot, + locales, + allowedFenceLanguages, + navigationPages: [], + exceptions: noExceptions, + }), + /navigation does not match/, + ) +}) + +test("rejects missing link targets and anchors", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + await docs.write("index.md", home.replace("./guide/page#details", "./guide/missing")) + + await assert.rejects(validate(docs.docsRoot), /links to missing target/) + + await docs.write("index.md", home.replace("#details", "#missing")) + await assert.rejects(validate(docs.docsRoot), /links to missing anchor/) + + await docs.write("index.md", home.replace("#details", "#")) + await assert.rejects(validate(docs.docsRoot), /has an empty anchor/) +}) + +test("does not resolve assets outside the documentation roots", async (context) => { + const docs = await fixture() + const outsideName = `${path.basename(docs.docsRoot)}-outside.txt` + const outsidePath = path.join(docs.docsRoot, "..", outsideName) + context.after(async () => { + await docs.cleanup() + await rm(outsidePath, { force: true }) + }) + await writeFile(outsidePath, "outside") + await docs.write("guide/page.md", `${guide}\n[Outside](../../${outsideName})\n`) + + await assert.rejects(validate(docs.docsRoot), /links to missing target/) +}) + +test("rejects canonical code drift and malformed fences", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + await docs.write("zh/index.md", home.replace("stack check", "stack fmt")) + + await assert.rejects(validate(docs.docsRoot), /does not preserve the canonical code examples/) + + await docs.write("zh/index.md", home.replace("```sh", "``` shell")) + await assert.rejects(validate(docs.docsRoot), /malformed or unlabelled code fence/) +}) + +test("rejects malformed or unsafe external links", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + await docs.write("index.md", home.replace("https://stack-diagram.com/", "http://example.com/")) + + await assert.rejects(validate(docs.docsRoot), /credential-free HTTPS link/) + + await docs.write("index.md", home.replace("https://stack-diagram.com/", "https://[invalid")) + await assert.rejects(validate(docs.docsRoot), /invalid external link/) +}) + +test("requires a reason for intentional locale differences", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + await docs.write("ko/index.md", home.replace("stack check", "stack fmt")) + + await assert.rejects( + validate(docs.docsRoot, { + ...noExceptions, + codeBlockParity: [{ locale: "ko", page: "index.md" }], + }), + /requires a non-empty reason/, + ) + + await validate(docs.docsRoot, { + ...noExceptions, + codeBlockParity: [ + { locale: "ko", page: "index.md", reason: "Locale-specific command walkthrough" }, + ], + }) +}) + +test("rejects stale exceptions", async (context) => { + const docs = await fixture() + context.after(docs.cleanup) + + await assert.rejects( + validate(docs.docsRoot, { + ...noExceptions, + headingParity: [{ locale: "ja", page: "index.md", reason: "No longer needed" }], + }), + /Unused documentation exceptions/, + ) +}) diff --git a/scripts/docs-validation.config.mjs b/scripts/docs-validation.config.mjs new file mode 100644 index 0000000..1fe1046 --- /dev/null +++ b/scripts/docs-validation.config.mjs @@ -0,0 +1,57 @@ +export const documentationContract = { + locales: ["ja", "zh", "ko"], + allowedFenceLanguages: ["stack", "sh", "text", "yaml"], + navigation: [ + { + label: "guide", + items: [ + { label: "introduction", page: "guide/what-is-stack.md" }, + { label: "gettingStarted", page: "guide/getting-started.md" }, + { label: "playground", page: "guide/playground.md" }, + { label: "providerIcons", page: "guide/provider-icons.md" }, + ], + }, + { + label: "language", + items: [ + { label: "syntax", page: "language/syntax.md" }, + { label: "nodesAndGroups", page: "language/nodes-and-groups.md" }, + { label: "edgesAndLayout", page: "language/edges-and-layout.md" }, + { label: "themesAndIcons", page: "language/themes-and-icons.md" }, + { label: "formatting", page: "language/formatting.md" }, + ], + }, + { + label: "reference", + items: [ + { label: "diagnosticsAndLimits", page: "reference/diagnostics-and-limits.md" }, + { label: "versioningAndSafety", page: "reference/versioning-and-safety.md" }, + ], + }, + ], + cli: { + repository: "stack-sh/cli", + revision: "04c47eca36e4a54be83604a8a4f3fb89d135507d", + version: "0.3.0", + executionExceptions: [ + { + prefix: "stack icons import ", + reason: + "Import downloads provider archives and records terms acceptance; CI validates its real help contract without performing the side effect.", + }, + ], + }, + stackExecutionExceptions: [ + { + page: "language/edges-and-layout.md", + block: 1, + reason: "This block is an intentionally isolated group-local layout fragment.", + }, + ], + exceptions: { + pageParity: [], + headingParity: [], + codeBlockParity: [], + links: [], + }, +} diff --git a/scripts/validate-docs-cli.mjs b/scripts/validate-docs-cli.mjs new file mode 100644 index 0000000..37d3c7d --- /dev/null +++ b/scripts/validate-docs-cli.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { promisify } from "node:util" + +import { validateDocumentationContract } from "./docs-contract.mjs" +import { documentationContract } from "./docs-validation.config.mjs" + +const execute = promisify(execFile) +const cli = process.env.STACK_CLI_BIN +if (!cli) throw new Error("STACK_CLI_BIN must point to the pinned Stack CLI binary") + +const docsRoot = path.resolve("docs") +const { documents, cliExamples } = await validateDocumentationContract({ + docsRoot, + locales: documentationContract.locales, + allowedFenceLanguages: documentationContract.allowedFenceLanguages, + navigationPages: documentationContract.navigation.flatMap((section) => + section.items.map((item) => item.page), + ), + exceptions: documentationContract.exceptions, +}) + +const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "stack-docs-cli-")) +try { + const environment = { + ...process.env, + XDG_CONFIG_HOME: path.join(temporaryDirectory, "config"), + } + const run = (args) => + execute(cli, args, { cwd: temporaryDirectory, env: environment, encoding: "utf8" }) + + const version = await run(["version"]) + assert.equal(version.stdout, `stack ${documentationContract.cli.version}\n`) + assert.equal(version.stderr, "") + + const topLevelHelp = await run(["help"]) + for (const command of ["check", "fmt", "render", "icons", "help", "version"]) { + assert.match(topLevelHelp.stdout, new RegExp(`\\b${command}\\b`)) + } + const renderHelp = await run(["help", "render"]) + for (const option of ["--provider-pack", "-o", "--notice"]) { + assert.ok(renderHelp.stdout.includes(option), `render help is missing ${option}`) + } + const importHelp = await run(["help", "icons", "import"]) + for (const option of ["--accept-terms", "-o"]) { + assert.ok(importHelp.stdout.includes(option), `icons import help is missing ${option}`) + } + + const exampleSource = documents + .get("guide/getting-started.md") + .codeBlocks.find((codeBlock) => codeBlock.language === "stack")?.body + if (!exampleSource) throw new Error("Getting started does not provide a Stack example") + await writeFile(path.join(temporaryDirectory, "architecture.stack"), `${exampleSource}\n`) + await mkdir(path.join(temporaryDirectory, ".stack-icons")) + + const configuredExceptions = new Map() + for (const exception of documentationContract.cli.executionExceptions) { + if (typeof exception.reason !== "string" || exception.reason.trim().length === 0) { + throw new Error("CLI example execution exceptions require a non-empty reason") + } + if (configuredExceptions.has(exception.prefix)) { + throw new Error(`Duplicate CLI example exception: ${exception.prefix}`) + } + configuredExceptions.set(exception.prefix, exception) + } + + const usedExceptions = new Set() + let executedExamples = 0 + for (const command of [...new Set(cliExamples)]) { + const exception = [...configuredExceptions.keys()].find((prefix) => command.startsWith(prefix)) + if (exception) { + usedExceptions.add(exception) + continue + } + const arguments_ = command.split(/\s+/) + if (arguments_.shift() !== "stack") throw new Error(`Invalid CLI example: ${command}`) + const result = await run(arguments_) + assert.doesNotMatch(result.stderr, /error\[/, `CLI example returned an error: ${command}`) + executedExamples += 1 + } + + const staleExceptions = [...configuredExceptions.keys()].filter( + (prefix) => !usedExceptions.has(prefix), + ) + if (staleExceptions.length > 0) { + throw new Error(`Unused CLI example exceptions: ${staleExceptions.join(", ")}`) + } + + const generatedSvg = await readFile(path.join(temporaryDirectory, "architecture.svg"), "utf8") + assert.match(generatedSvg, / + section.items.map((item) => item.page), + ), + exceptions: documentationContract.exceptions, +}) + +const exceptions = new Map() +for (const exception of documentationContract.stackExecutionExceptions) { + if (typeof exception.reason !== "string" || exception.reason.trim().length === 0) { + throw new Error("Stack example execution exceptions require a non-empty reason") + } + const key = `${exception.page}:${exception.block}` + if (exceptions.has(key)) throw new Error(`Duplicate Stack example exception: ${key}`) + exceptions.set(key, exception) +} + +const usedExceptions = new Set() +let checkedExamples = 0 +for (const page of pages) { + let stackBlockIndex = 0 + for (const codeBlock of documents.get(page).codeBlocks) { + if (codeBlock.language !== "stack") continue + const key = `${page}:${stackBlockIndex}` + stackBlockIndex += 1 + if (exceptions.has(key)) { + usedExceptions.add(key) + continue + } + + const result = check(codeBlock.body) + const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error") + if (errors.length > 0) { + throw new Error( + `${page}:${codeBlock.line} is not valid in @stack-sh/engine ${packageMetadata.dependencies["@stack-sh/engine"]}: ${errors.map((diagnostic) => diagnostic.code).join(", ")}`, + ) + } + if (result.metadata.engineVersion !== packageMetadata.dependencies["@stack-sh/engine"]) { + throw new Error(`${page}:${codeBlock.line} ran against an unexpected Engine version`) + } + checkedExamples += 1 + } +} + +const staleExceptions = [...exceptions.keys()].filter((key) => !usedExceptions.has(key)) +if (staleExceptions.length > 0) { + throw new Error(`Unused Stack example exceptions: ${staleExceptions.join(", ")}`) +} + +console.log( + `Checked ${checkedExamples} complete Stack examples with @stack-sh/engine ${packageMetadata.dependencies["@stack-sh/engine"]}; ${usedExceptions.size} documented fragment was excluded.`, +) diff --git a/scripts/validate-docs.mjs b/scripts/validate-docs.mjs index 64b5acc..612bf2b 100644 --- a/scripts/validate-docs.mjs +++ b/scripts/validate-docs.mjs @@ -1,23 +1,20 @@ import { readFile } from "node:fs/promises" import path from "node:path" -const pages = [ - "index.md", - "guide/what-is-stack.md", - "guide/getting-started.md", - "guide/playground.md", - "guide/provider-icons.md", - "language/syntax.md", - "language/nodes-and-groups.md", - "language/edges-and-layout.md", - "language/themes-and-icons.md", - "language/formatting.md", - "reference/diagnostics-and-limits.md", - "reference/versioning-and-safety.md", -] +import { validateDocumentationContract } from "./docs-contract.mjs" +import { documentationContract } from "./docs-validation.config.mjs" -const locales = ["ja", "zh", "ko"] const docsRoot = path.resolve("docs") +const locales = documentationContract.locales +const { pages, englishPages } = await validateDocumentationContract({ + docsRoot, + locales, + allowedFenceLanguages: documentationContract.allowedFenceLanguages, + navigationPages: documentationContract.navigation.flatMap((section) => + section.items.map((item) => item.page), + ), + exceptions: documentationContract.exceptions, +}) const packageMetadata = JSON.parse(await readFile(path.resolve("package.json"), "utf8")) const providerCatalog = JSON.parse( await readFile(path.join(docsRoot, ".vitepress/theme/data/provider-catalogs.json"), "utf8"), @@ -158,38 +155,6 @@ if (!rootSitemap.includes("https://stack-diagram.com/")) { throw new Error("Root sitemap does not include the Playground") } -function codeBlocks(source) { - return [...source.matchAll(/```stack\n([\s\S]*?)```/g)].map((match) => match[1]) -} - -function headingCount(source) { - return source.match(/^#{1,4}\s+/gm)?.length ?? 0 -} - -const englishPages = new Map() - -for (const page of pages) { - const source = await readFile(path.join(docsRoot, page), "utf8") - englishPages.set(page, source) -} - -for (const locale of locales) { - for (const page of pages) { - const english = englishPages.get(page) - const translated = await readFile(path.join(docsRoot, locale, page), "utf8") - - if (headingCount(translated) !== headingCount(english)) { - throw new Error( - `${locale}/${page} does not have the same heading coverage as the English page`, - ) - } - - if (JSON.stringify(codeBlocks(translated)) !== JSON.stringify(codeBlocks(english))) { - throw new Error(`${locale}/${page} does not preserve the canonical Stack examples`) - } - } -} - for (const [page, source] of englishPages) { if (page === "index.md" && !source.includes("light: /favicon.svg")) { throw new Error("English documentation home does not use the Stack logo")