From f4ccfee64ae831d027d726655f327d576dbc48d3 Mon Sep 17 00:00:00 2001 From: "kubestellar-hive[bot]" Date: Fri, 18 Sep 2026 12:57:30 -0400 Subject: [PATCH] test: cover the docs/ markdown link and frontmatter contract Nothing in tests/ reads the docs/ tree, and docusaurus.config.js sets onBrokenMarkdownLinks to 'warn', so a relative doc-to-doc link that stops resolving only warns and the site still deploys with a dead link. Add tests/docs-contract.test.mjs asserting that relative doc-to-doc links resolve on disk, that link #fragments match a heading in the target doc, that any frontmatter fence that opens is terminated and parses as key: value pairs, and that docs under an autogenerated sidebar declare a title. Closes #294 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: kubestellar-hive[bot] --- tests/docs-contract.test.mjs | 182 +++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/docs-contract.test.mjs diff --git a/tests/docs-contract.test.mjs b/tests/docs-contract.test.mjs new file mode 100644 index 00000000..489e3ad5 --- /dev/null +++ b/tests/docs-contract.test.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const docsRoot = join(repoRoot, 'docs'); + +// Directories sidebars.js renders as `{type: 'autogenerated', dirName: ...}`. +// Every doc in one of these becomes a sidebar entry, which is labelled from +// frontmatter `title` when present. +const AUTOGENERATED_SIDEBAR_DIRS = ['architectures', 'community']; + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + return entry.isDirectory() ? walk(full) : [full]; + }); +} + +const docs = walk(docsRoot) + .filter((file) => /\.mdx?$/.test(file)) + .sort(); + +function readDoc(file) { + const raw = readFileSync(file, 'utf8'); + const fence = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return { + raw, + frontmatter: fence ? fence[1] : null, + body: fence ? raw.slice(fence[0].length) : raw, + }; +} + +// Mirrors Docusaurus' GitHub-flavoured heading slugs closely enough to catch a +// renamed or deleted heading, which is all this contract needs to detect. +function headingSlug(text) { + return text + .replace(/`/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .toLowerCase() + .replace(/[^a-z0-9 -]/g, '') + .trim() + .replace(/\s+/g, '-'); +} + +function headingSlugs(body) { + return [...body.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map(([, text]) => + headingSlug(text), + ); +} + +// Relative markdown links between docs, i.e. the ones Docusaurus resolves on +// disk. Absolute routes (/foo), external URLs and mailto: are resolved by +// `onBrokenLinks: 'throw'` at build time and are not this test's contract. +function relativeLinks(body) { + return [...body.matchAll(/\[(?:[^\]]*)\]\(([^)\s]+)\)/g)] + .map(([, href]) => href) + .filter((href) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(href)); +} + +function resolveDocPath(candidate) { + for (const path of [candidate, `${candidate}.md`, `${candidate}.mdx`]) { + try { + if (statSync(path).isFile()) return path; + } catch { + // Not a file at this extension; try the next candidate. + } + } + return null; +} + +test('the docs/ tree contains documents to check', () => { + assert.ok( + docs.length > 0, + 'expected docs/ to contain at least one .md or .mdx file', + ); +}); + +test('every relative doc-to-doc link resolves to a file on disk', () => { + const broken = []; + for (const file of docs) { + const { body } = readDoc(file); + for (const href of relativeLinks(body)) { + const [path] = href.split('#'); + if (!path) continue; + const target = resolveDocPath(resolve(dirname(file), path)); + if (!target) { + broken.push(`${relative(repoRoot, file)} -> ${href}`); + } + } + } + assert.deepEqual( + broken, + [], + `relative links in docs/ must point at files that exist; onBrokenMarkdownLinks is 'warn', so the site build will not catch these`, + ); +}); + +test('every link fragment matches a heading in its target document', () => { + const broken = []; + for (const file of docs) { + const { body } = readDoc(file); + for (const href of relativeLinks(body)) { + const [path, fragment] = href.split('#'); + if (!fragment) continue; + const target = path ? resolveDocPath(resolve(dirname(file), path)) : file; + if (!target) continue; // Already reported by the resolution test. + const slugs = headingSlugs(readDoc(target).body); + if (!slugs.includes(fragment.toLowerCase())) { + broken.push(`${relative(repoRoot, file)} -> ${href}`); + } + } + } + assert.deepEqual( + broken, + [], + 'link fragments in docs/ must match a heading in the document they point at', + ); +}); + +test('every frontmatter fence that opens is terminated', () => { + const unterminated = docs.filter((file) => { + const raw = readFileSync(file, 'utf8'); + if (!/^---\r?\n/.test(raw)) return false; + return !/^---\r?\n[\s\S]*?\r?\n---/.test(raw); + }); + assert.deepEqual( + unterminated.map((file) => relative(repoRoot, file)), + [], + 'a doc that opens a --- frontmatter fence must close it, or Docusaurus renders the metadata as body text', + ); +}); + +test('frontmatter keys are parseable key: value pairs', () => { + const malformed = []; + for (const file of docs) { + const { frontmatter } = readDoc(file); + if (frontmatter === null) continue; + const lines = frontmatter.split(/\r?\n/); + const hasKey = lines.some((line) => /^[A-Za-z_][\w-]*\s*:/.test(line)); + if (!hasKey) { + malformed.push(relative(repoRoot, file)); + continue; + } + for (const line of lines) { + if (line.trim() === '') continue; + // Continuations and list items are indented; only top-level lines must + // be `key:` pairs. + if (/^\s/.test(line)) continue; + if (!/^[A-Za-z_][\w-]*\s*:/.test(line)) { + malformed.push(`${relative(repoRoot, file)}: ${line}`); + } + } + } + assert.deepEqual(malformed, [], 'docs/ frontmatter must be key: value pairs'); +}); + +test('docs in autogenerated sidebar directories declare a title', () => { + const untitled = []; + for (const dir of AUTOGENERATED_SIDEBAR_DIRS) { + const dirDocs = docs.filter((file) => + relative(docsRoot, file).startsWith(`${dir}/`), + ); + assert.ok( + dirDocs.length > 0, + `sidebars.js autogenerates a sidebar from docs/${dir}, which must contain documents`, + ); + for (const file of dirDocs) { + const { frontmatter } = readDoc(file); + if (frontmatter === null || !/^title:\s*\S/m.test(frontmatter)) { + untitled.push(relative(repoRoot, file)); + } + } + } + assert.deepEqual( + untitled, + [], + 'docs under an autogenerated sidebar need a frontmatter title, otherwise the sidebar label falls back to the filename', + ); +});