docs: own public documentation metadata (#1713)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
|
||||
/**
|
||||
* Validate that every published Markdown page has frontmatter and appears
|
||||
* exactly once in the public navigation metadata.
|
||||
*
|
||||
* @param {string} [docsDir] Directory containing published docs and meta.json.
|
||||
* @returns {Promise<{pageCount: number}>} Number of validated published pages.
|
||||
*/
|
||||
export async function validatePublicDocs(
|
||||
docsDir = path.join(repoRoot, "docs/public"),
|
||||
) {
|
||||
const meta = await readMeta(docsDir);
|
||||
const files = await collectMarkdownFiles(docsDir);
|
||||
const pagesBySlug = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
if (path.posix.basename(file).toLowerCase() === "readme.md") continue;
|
||||
|
||||
const markdown = await fs.readFile(path.join(docsDir, file), "utf8");
|
||||
assertFrontmatter(file, markdown);
|
||||
|
||||
const slug = file.replace(/\.mdx?$/, "").replace(/\/index$/, "");
|
||||
const existing = pagesBySlug.get(slug);
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`multiple published files resolve to slug ${slug}: ${existing}, ${file}`,
|
||||
);
|
||||
}
|
||||
pagesBySlug.set(slug, file);
|
||||
}
|
||||
|
||||
const listed = new Set();
|
||||
for (const entry of meta.pages) {
|
||||
if (isNavigationDecoration(entry)) continue;
|
||||
if (listed.has(entry)) {
|
||||
throw new Error(`meta.json lists page more than once: ${entry}`);
|
||||
}
|
||||
if (!pagesBySlug.has(entry)) {
|
||||
throw new Error(`meta.json references unknown page: ${entry}`);
|
||||
}
|
||||
|
||||
listed.add(entry);
|
||||
}
|
||||
|
||||
for (const slug of pagesBySlug.keys()) {
|
||||
if (!listed.has(slug)) {
|
||||
throw new Error(`meta.json is missing published page: ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pageCount: pagesBySlug.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate the shape of public navigation metadata.
|
||||
*
|
||||
* @param {string} docsDir Directory containing meta.json.
|
||||
* @returns {Promise<{pages: string[]}>} Parsed navigation metadata.
|
||||
*/
|
||||
async function readMeta(docsDir) {
|
||||
const raw = await fs.readFile(path.join(docsDir, "meta.json"), "utf8");
|
||||
const meta = JSON.parse(raw);
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
throw new Error("meta.json must contain a JSON object");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(meta.pages) ||
|
||||
!meta.pages.every((entry) => typeof entry === "string")
|
||||
) {
|
||||
throw new Error("meta.json pages must be an array of strings");
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect Markdown paths relative to the published docs root.
|
||||
*
|
||||
* @param {string} dir Published docs root.
|
||||
* @param {string} [relativeDir] Directory relative to the docs root.
|
||||
* @returns {Promise<string[]>} Sorted relative Markdown paths.
|
||||
*/
|
||||
async function collectMarkdownFiles(dir, relativeDir = "") {
|
||||
const entries = await fs.readdir(path.join(dir, relativeDir), {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const relativePath = path.posix.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return collectMarkdownFiles(dir, relativePath);
|
||||
}
|
||||
|
||||
return /\.mdx?$/.test(entry.name) ? [relativePath] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
return files.flat().sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Require non-empty title and description fields in leading frontmatter.
|
||||
*
|
||||
* @param {string} file Relative page path used in validation errors.
|
||||
* @param {string} markdown Page source.
|
||||
* @returns {void}
|
||||
*/
|
||||
function assertFrontmatter(file, markdown) {
|
||||
const block = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
||||
if (
|
||||
!block ||
|
||||
!/^title:\s*\S.*$/m.test(block) ||
|
||||
!/^description:\s*\S.*$/m.test(block)
|
||||
) {
|
||||
throw new Error(
|
||||
`${file} must start with YAML frontmatter containing title and description`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether a metadata entry is a navigation heading.
|
||||
*
|
||||
* @param {string} entry Navigation metadata entry.
|
||||
* @returns {boolean} Whether the entry is a heading decoration.
|
||||
*/
|
||||
function isNavigationDecoration(entry) {
|
||||
return /^---.*---$/.test(entry);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
validatePublicDocs()
|
||||
.then(({ pageCount }) =>
|
||||
console.log(`Validated ${pageCount} published docs pages.`),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { after } from "node:test";
|
||||
import { validatePublicDocs } from "./validate-public-docs.mjs";
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
/**
|
||||
* Create an isolated published-docs fixture.
|
||||
*
|
||||
* @param {Record<string, string>} files Fixture files keyed by relative path.
|
||||
* @param {{pages: string[]}} meta Navigation metadata.
|
||||
* @returns {Promise<string>} Temporary fixture directory.
|
||||
*/
|
||||
async function createDocs(files, meta) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-public-docs-"));
|
||||
tempDirs.push(dir);
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([file, content]) => {
|
||||
const target = path.join(dir, file);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, content);
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(meta));
|
||||
return dir;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
const validPage = `---
|
||||
title: "Overview"
|
||||
description: "Start using Kandev."
|
||||
---
|
||||
|
||||
# Kandev
|
||||
|
||||
Page body.
|
||||
`;
|
||||
|
||||
test("accepts explicitly ordered pages with required frontmatter", async () => {
|
||||
const dir = await createDocs(
|
||||
{
|
||||
"README.md": "# Contributing",
|
||||
"index.md": validPage,
|
||||
"cli.md": validPage,
|
||||
},
|
||||
{ title: "Kandev Docs", pages: ["---Start---", "index", "cli"] },
|
||||
);
|
||||
|
||||
await assert.doesNotReject(validatePublicDocs(dir));
|
||||
});
|
||||
|
||||
test("rejects published pages omitted from meta.json", async () => {
|
||||
const dir = await createDocs(
|
||||
{ "index.md": validPage, "cli.md": validPage },
|
||||
{ pages: ["index"] },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
validatePublicDocs(dir),
|
||||
/meta.json is missing published page: cli/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects meta.json entries without a matching file", async () => {
|
||||
const dir = await createDocs(
|
||||
{ "index.md": validPage },
|
||||
{ pages: ["index", "nonexistent"] },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
validatePublicDocs(dir),
|
||||
/meta.json references unknown page: nonexistent/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unsupported link decorations as unknown pages", async () => {
|
||||
const dir = await createDocs(
|
||||
{ "index.md": validPage },
|
||||
{ pages: ["index", "external:[Support](https://example.com)"] },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
validatePublicDocs(dir),
|
||||
/meta.json references unknown page: external:\[Support\]\(https:\/\/example.com\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate entries in meta.json", async () => {
|
||||
const dir = await createDocs(
|
||||
{ "index.md": validPage },
|
||||
{ pages: ["index", "index"] },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
validatePublicDocs(dir),
|
||||
/meta.json lists page more than once: index/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects files that resolve to the same published slug", async () => {
|
||||
const dir = await createDocs(
|
||||
{ "foo.md": validPage, "foo/index.md": validPage },
|
||||
{ pages: ["foo"] },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
validatePublicDocs(dir),
|
||||
/multiple published files resolve to slug foo: foo.md, foo\/index.md/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts single-character frontmatter values", async () => {
|
||||
const dir = await createDocs(
|
||||
{
|
||||
"index.md": `---
|
||||
title: x
|
||||
description: y
|
||||
---
|
||||
|
||||
# X
|
||||
`,
|
||||
},
|
||||
{ pages: ["index"] },
|
||||
);
|
||||
|
||||
await assert.doesNotReject(validatePublicDocs(dir));
|
||||
});
|
||||
|
||||
test("rejects published pages without title and description frontmatter", async () => {
|
||||
const dir = await createDocs(
|
||||
{ "index.md": "# Kandev\n" },
|
||||
{ pages: ["index"] },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
validatePublicDocs(dir),
|
||||
/index.md must start with YAML frontmatter containing title and description/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user