Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions tests/docs-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -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',
);
});
Loading