diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b227f3ab..47e2db8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,10 @@ jobs: - name: Lint (anvil) run: pnpm --filter ./apps/docs run check-anvil + # Each option the catalog lists is in its card page's tables, and each row is still one. + - name: Lint (option tables) + run: pnpm --filter ./apps/docs run check-option-tables + # Once, for both the previews and the site; `code-checks` typechecks the site. - name: Build packages run: pnpm run build:packages diff --git a/apps/docs/AGENTS.md b/apps/docs/AGENTS.md index 0274ca93..533fdb18 100644 --- a/apps/docs/AGENTS.md +++ b/apps/docs/AGENTS.md @@ -18,6 +18,9 @@ carries a server changeset. - **Every example `uses:` is `/@ # vX.Y.Z`**, the shape Renovate's custom manager reads ([#114]). - **`public/favicon-self-hosted.svg` is `favicon.svg` with blue bars**: change both. +- **`check-option-tables` holds each card page to the catalog**: every option it lists sits in an + `Option` table on the card's page or is named on `customization/common-options.md`, and every + `Option` table row is one the card takes. ## Building and checking diff --git a/apps/docs/package.json b/apps/docs/package.json index 9d15a167..d54750cb 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,6 +11,7 @@ "check-cards": "node scripts/generate-cards.ts --check", "test:live": "node --conditions=@stats/source --env-file-if-exists=../../.env scripts/test-live.ts", "check-anvil": "node --conditions=@stats/source scripts/check-anvil.ts", + "check-option-tables": "node --conditions=@stats/source scripts/check-option-tables.ts", "test:e2e": "playwright test", "generate-themes-page": "node --conditions=@stats/source scripts/generate-themes-page.ts", "check-themes-page": "node --conditions=@stats/source scripts/generate-themes-page.ts --check", diff --git a/apps/docs/scripts/check-option-tables.ts b/apps/docs/scripts/check-option-tables.ts new file mode 100644 index 00000000..e939097c --- /dev/null +++ b/apps/docs/scripts/check-option-tables.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * @file Fails when a card's page and the catalog disagree on the options the card takes. + * + * The tables are written by hand for their prose, so nothing else notices one falling behind. + * + * @returns Nothing; the process exits non-zero naming each option out of step. + */ + +import { readFileSync } from 'node:fs'; + +import { cards } from '@stats-forge/github-stats-forge-catalog'; + +import { CARDS } from '../src/anvil/cards.ts'; + +const PAGES = new URL('../src/content/docs/docs/', import.meta.url); +const COMMON_PAGE = 'customization/common-options.md'; + +/** @returns The backticked names in `text`, such as both of `` `from` / `to` ``, or one given a value. */ +const namesIn = (text: string): Array => + [...text.matchAll(/`(?[a-z_]+)(?:=[^`]*)?`/g)].flatMap( + (match) => match.groups?.['name'] ?? [], + ); + +/** @returns The names in the first column of each table whose first header reads `Option`. */ +const optionTableNames = (page: string): Set => { + const names = new Set(); + let inOptionTable = false; + let header = true; + for (const line of page.split('\n')) { + if (!line.startsWith('|')) { + header = true; + inOptionTable = false; + continue; + } + const firstCell = line.split('|')[1]?.trim() ?? ''; + if (header) { + inOptionTable = firstCell === 'Option'; + header = false; + continue; + } + if (inOptionTable) { + for (const name of namesIn(firstCell)) { + names.add(name); + } + } + } + return names; +}; + +const read = (path: string): string => readFileSync(new URL(path, PAGES), 'utf8'); + +const common = read(COMMON_PAGE); +const commonNames = new Set(namesIn(common)); +const failures: Array = []; + +for (const card of cards) { + const docs = CARDS.find((extra) => extra.id === card.id)?.docs; + if (docs === undefined) { + failures.push(`${card.id}: the anvil names no page for it`); + continue; + } + const path = `cards/${docs}.md`; + const documented = optionTableNames(read(path)); + const taken = new Set([...card.required, ...card.options].map((option) => option.name)); + + for (const name of taken) { + if (!documented.has(name) && !commonNames.has(name)) { + failures.push(`${path}: \`${name}\` is not in an Option table, nor on ${COMMON_PAGE}`); + } + } + for (const name of documented) { + if (!taken.has(name)) { + failures.push( + `${path}: \`${name}\` is in an Option table, but the ${card.id} card takes no such option`, + ); + } + } +} + +const takenByAny = new Set(cards.flatMap((card) => card.options.map((option) => option.name))); +for (const name of optionTableNames(common)) { + if (!takenByAny.has(name)) { + failures.push(`${COMMON_PAGE}: \`${name}\` is in an Option table, but no card takes it`); + } +} + +if (failures.length > 0) { + process.stderr.write(`${failures.join('\n')}\n`); + process.exitCode = 1; +} else { + process.stderr.write(` ✓ every option of ${String(cards.length)} cards is documented\n`); +} diff --git a/scripts/check-all.ts b/scripts/check-all.ts index ccc195cc..54c33e73 100644 --- a/scripts/check-all.ts +++ b/scripts/check-all.ts @@ -21,6 +21,7 @@ const CHECKS: Record> = { 'graphql types': ['--filter', './packages/core', 'run', 'check-graphql-types'], 'themes page': ['--filter', './apps/docs', 'run', 'check-themes-page'], anvil: ['--filter', './apps/docs', 'run', 'check-anvil'], + 'option tables': ['--filter', './apps/docs', 'run', 'check-option-tables'], // after `typecheck`, which leaves the CLI built for it 'card previews': ['--filter', './apps/docs', 'run', 'check-cards'], 'docs site': ['run', 'docs:build'],