diff --git a/Makefile b/Makefile index 58df4ac5f..854e8669c 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,7 @@ docs-site: docs-commands @node scripts/build-docs-site.mjs docs-check: docs-site + @node --test scripts/check-docs-coverage.test.mjs @node scripts/check-docs-coverage.mjs tools: diff --git a/scripts/check-docs-coverage.mjs b/scripts/check-docs-coverage.mjs index 65de52d92..b1ea80962 100644 --- a/scripts/check-docs-coverage.mjs +++ b/scripts/check-docs-coverage.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const root = process.cwd(); const bin = process.env.GOG_BIN || path.join(root, "bin", "gog"); @@ -34,53 +35,66 @@ const requiredFeatureDocs = [ "dates.md", ]; -const schema = JSON.parse(execFileSync(bin, ["schema", "--json"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })); -const commands = Array.from(walk(schema.command || {})); -const seenSlugs = new Set(); -const missingCommandPages = []; - -for (const command of commands) { - const base = commandSlug(command); - let slug = base; - let suffix = 2; - while (seenSlugs.has(slug)) { - slug = `${base}-${suffix}`; - suffix += 1; - } - seenSlugs.add(slug); +function main() { + const schema = JSON.parse( + execFileSync(bin, ["schema", "--json"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }), + ); + const commands = Array.from(walk(schema.command || {})); + const seenSlugs = new Set(); + const missingCommandPages = []; + + for (const command of commands) { + const base = commandSlug(command); + let slug = base; + let suffix = 2; + while (seenSlugs.has(slug)) { + slug = `${base}-${suffix}`; + suffix += 1; + } + seenSlugs.add(slug); - const page = path.join(commandsDir, `${slug}.md`); - if (!fs.existsSync(page)) { - missingCommandPages.push(path.relative(root, page)); + const page = path.join(commandsDir, `${slug}.md`); + if (!fs.existsSync(page)) { + missingCommandPages.push(path.relative(root, page)); + } } -} -const navSourcePath = path.join(root, "scripts", "build-docs-site.mjs"); -const navSource = fs.readFileSync(navSourcePath, "utf8"); -const missingFeaturePages = []; -const unlinkedFeaturePages = []; -const brokenLinks = checkMarkdownLinks(docsDir); - -for (const rel of requiredFeatureDocs) { - const page = path.join(docsDir, rel); - if (!fs.existsSync(page)) { - missingFeaturePages.push(`docs/${rel}`); - continue; + const navSourcePath = path.join(root, "scripts", "build-docs-site.mjs"); + const navSource = fs.readFileSync(navSourcePath, "utf8"); + const missingFeaturePages = []; + const unlinkedFeaturePages = []; + const brokenLinks = checkMarkdownLinks(docsDir); + + for (const rel of requiredFeatureDocs) { + const page = path.join(docsDir, rel); + if (!fs.existsSync(page)) { + missingFeaturePages.push(`docs/${rel}`); + continue; + } + if (!navSource.includes(`"${rel}"`)) { + unlinkedFeaturePages.push(`docs/${rel}`); + } } - if (!navSource.includes(`"${rel}"`)) { - unlinkedFeaturePages.push(`docs/${rel}`); + + if ( + missingCommandPages.length || + missingFeaturePages.length || + unlinkedFeaturePages.length || + brokenLinks.length + ) { + for (const name of missingCommandPages) console.error(`missing command doc: ${name}`); + for (const name of missingFeaturePages) console.error(`missing feature doc: ${name}`); + for (const name of unlinkedFeaturePages) console.error(`feature doc not in scripts/build-docs-site.mjs sidebar: ${name}`); + for (const item of brokenLinks) console.error(`broken docs link: ${item}`); + process.exit(1); } -} -if (missingCommandPages.length || missingFeaturePages.length || unlinkedFeaturePages.length || brokenLinks.length) { - for (const name of missingCommandPages) console.error(`missing command doc: ${name}`); - for (const name of missingFeaturePages) console.error(`missing feature doc: ${name}`); - for (const name of unlinkedFeaturePages) console.error(`feature doc not in scripts/build-docs-site.mjs sidebar: ${name}`); - for (const item of brokenLinks) console.error(`broken docs link: ${item}`); - process.exit(1); + console.log(`docs coverage ok: ${commands.length} command pages, ${requiredFeatureDocs.length} feature pages`); } -console.log(`docs coverage ok: ${commands.length} command pages, ${requiredFeatureDocs.length} feature pages`); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} function* walk(command) { yield command; @@ -107,31 +121,104 @@ function commandSlug(command) { return slug || "gog"; } -function checkMarkdownLinks(dir) { +export function checkMarkdownLinks(dir) { const broken = []; for (const file of allMarkdown(dir)) { const markdown = fs.readFileSync(file, "utf8"); + const headings = headingAnchors(markdown); const linkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g; let match; while ((match = linkPattern.exec(markdown)) !== null) { - const rawTarget = match[1].trim().replace(/^<|>$/g, ""); - if (!rawTarget || rawTarget.startsWith("#")) continue; + const rawTarget = splitMarkdownTarget(match[1].trim()); + if (!rawTarget) continue; if (/^[a-z][a-z0-9+.-]*:/i.test(rawTarget)) continue; - const targetWithoutTitle = rawTarget.split(/\s+["'][^"']*["']\s*$/)[0]; - const targetPath = targetWithoutTitle.split("#")[0]; - if (!targetPath) continue; + const [rawPath, rawAnchor] = rawTarget.split("#", 2); + const targetPath = decodeMarkdownTarget(rawPath); if (/^(url|path|file)$/i.test(targetPath)) continue; - const resolved = path.resolve(path.dirname(file), targetPath); + const resolved = targetPath ? path.resolve(path.dirname(file), targetPath) : file; if (!fs.existsSync(resolved)) { broken.push(`${path.relative(root, file)} -> ${targetPath}`); + continue; + } + + if (rawAnchor && resolved.toLowerCase().endsWith(".md")) { + const anchor = decodeMarkdownTarget(rawAnchor); + const targetHeadings = resolved === file ? headings : headingAnchors(fs.readFileSync(resolved, "utf8")); + if (!targetHeadings.has(anchor)) { + broken.push(`${path.relative(root, file)} -> ${rawTarget}`); + } } } } return broken; } +function splitMarkdownTarget(rawTarget) { + const targetWithoutTitle = rawTarget.replace(/\s+["'][^"']*["']\s*$/, ""); + return targetWithoutTitle.replace(/^<|>$/g, ""); +} + +function decodeMarkdownTarget(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function headingAnchors(markdown) { + const anchors = new Set(); + const occurrences = new Map(); + let fence = null; + for (const rawLine of markdown.split("\n")) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const fenceMatch = line.match(/^(?: {0,3})(`{3,}|~{3,})(.*)$/); + if (fence) { + if ( + fenceMatch && + fenceMatch[1][0] === fence.char && + fenceMatch[1].length >= fence.length && + fenceMatch[2].trim() === "" + ) { + fence = null; + } + continue; + } + if (fenceMatch) { + fence = { char: fenceMatch[1][0], length: fenceMatch[1].length }; + continue; + } + + const match = line.match(/^(#{1,6})\s+(.*)$/); + if (!match) continue; + + const heading = match[2].replace(/\s+#+\s*$/, "").trim(); + const base = slugifyHeading(heading); + if (!base) continue; + + let anchor = base; + while (occurrences.has(anchor)) { + const count = (occurrences.get(base) || 0) + 1; + occurrences.set(base, count); + anchor = `${base}-${count}`; + } + occurrences.set(anchor, 0); + anchors.add(anchor); + } + return anchors; +} + +function slugifyHeading(text) { + return text + .replace(/<[^>]*>/g, "") + .replace(/`/g, "") + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, "") + .replace(/ /g, "-"); +} + function allMarkdown(dir) { return fs .readdirSync(dir, { withFileTypes: true }) diff --git a/scripts/check-docs-coverage.test.mjs b/scripts/check-docs-coverage.test.mjs new file mode 100644 index 000000000..c858ad7fb --- /dev/null +++ b/scripts/check-docs-coverage.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { checkMarkdownLinks, headingAnchors } from "./check-docs-coverage.mjs"; + +test("headingAnchors ignores headings inside fenced code blocks", () => { + const anchors = headingAnchors(`# Real Heading + +\`\`\`md +# Not A Heading +## Duplicate +\`\`\` + +## Duplicate +## Duplicate + +~~~text +# Also Not A Heading +~~~ +`); + + assert.equal(anchors.has("not-a-heading"), false); + assert.equal(anchors.has("also-not-a-heading"), false); + assert.deepEqual([...anchors], ["real-heading", "duplicate", "duplicate-1"]); +}); + +test("headingAnchors follows GitHub-style heading slugs", () => { + const anchors = headingAnchors(`# What's new? +## Привет non-latin 你好 +## A B +## foo +## foo +## foo-1 +## Heading ## +`); + + assert.deepEqual([...anchors], [ + "whats-new", + "привет-non-latin-你好", + "a--b", + "foo", + "foo-1", + "foo-1-1", + "heading", + ]); +}); + +test("checkMarkdownLinks accepts encoded Unicode anchors", (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gog-doc-links-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(dir, "target.md"), "# Привет мир\n"); + fs.writeFileSync( + path.join(dir, "index.md"), + [ + "[valid](target.md#%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82-%D0%BC%D0%B8%D1%80)", + "[broken](target.md#missing)", + "", + ].join("\n"), + ); + + const broken = checkMarkdownLinks(dir); + assert.equal(broken.length, 1); + assert.match(broken[0], /target\.md#missing$/); +});