From 34bbbc38b2c90341c813a6127ae3417c60d751f0 Mon Sep 17 00:00:00 2001 From: Kiran Magic Date: Sun, 14 Jun 2026 22:50:16 +0530 Subject: [PATCH 1/3] test(docs): validate markdown link anchors --- scripts/check-docs-coverage.mjs | 74 ++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/scripts/check-docs-coverage.mjs b/scripts/check-docs-coverage.mjs index 65de52d92..2f8880147 100644 --- a/scripts/check-docs-coverage.mjs +++ b/scripts/check-docs-coverage.mjs @@ -111,27 +111,89 @@ 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 [targetPath, rawAnchor] = rawTarget.split("#", 2); 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.endsWith(".md")) { + const targetHeadings = resolved === file ? headings : headingAnchors(fs.readFileSync(resolved, "utf8")); + if (!targetHeadings.has(rawAnchor)) { + broken.push(`${path.relative(root, file)} -> ${rawTarget}`); + } } } } return broken; } +function splitMarkdownTarget(rawTarget) { + const targetWithoutTitle = rawTarget.replace(/\s+["'][^"']*["']\s*$/, ""); + return targetWithoutTitle.replace(/^<|>$/g, ""); +} + +function headingAnchors(markdown) { + const anchors = new Set(); + const seen = new Map(); + for (const rawLine of markdown.split("\n")) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const match = line.match(/^(#{1,6})\s+(.*)$/); + if (!match) continue; + + const base = slugifyHeading(match[2]); + if (!base) continue; + + const count = seen.get(base) || 0; + seen.set(base, count + 1); + anchors.add(count === 0 ? base : `${base}-${count}`); + } + return anchors; +} + +function slugifyHeading(text) { + let out = ""; + let inTag = false; + let lastDash = false; + for (const char of text.toLowerCase()) { + if (char === "`") { + continue; + } + if (char === "<") { + inTag = true; + continue; + } + if (char === ">") { + inTag = false; + continue; + } + if (inTag) { + continue; + } + const code = char.charCodeAt(0); + const ok = (code >= 97 && code <= 122) || (code >= 48 && code <= 57); + if (ok) { + out += char; + lastDash = false; + } else if (!lastDash) { + out += "-"; + lastDash = true; + } + } + return out.replace(/^-+|-+$/g, ""); +} + function allMarkdown(dir) { return fs .readdirSync(dir, { withFileTypes: true }) From ca4778c70c893a9ca2098f0c25a04f2ffc0a6dfa Mon Sep 17 00:00:00 2001 From: Kiran Magic Date: Mon, 15 Jun 2026 01:25:06 +0530 Subject: [PATCH 2/3] test(docs): ignore fenced headings in anchor checks --- Makefile | 1 + scripts/check-docs-coverage.mjs | 112 +++++++++++++++++---------- scripts/check-docs-coverage.test.mjs | 25 ++++++ 3 files changed, 98 insertions(+), 40 deletions(-) create mode 100644 scripts/check-docs-coverage.test.mjs 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 2f8880147..45fa188d6 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; @@ -144,11 +158,29 @@ function splitMarkdownTarget(rawTarget) { return targetWithoutTitle.replace(/^<|>$/g, ""); } -function headingAnchors(markdown) { +export function headingAnchors(markdown) { const anchors = new Set(); const seen = 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; diff --git a/scripts/check-docs-coverage.test.mjs b/scripts/check-docs-coverage.test.mjs new file mode 100644 index 000000000..d7a729f5c --- /dev/null +++ b/scripts/check-docs-coverage.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { 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"]); +}); From ae43ad9454452f63a1007129254bf72898a8dd8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 01:30:31 -0400 Subject: [PATCH 3/3] test(docs): match GitHub heading anchor slugs Co-authored-by: Kiran Magic --- scripts/check-docs-coverage.mjs | 69 +++++++++++++--------------- scripts/check-docs-coverage.test.mjs | 45 +++++++++++++++++- 2 files changed, 75 insertions(+), 39 deletions(-) diff --git a/scripts/check-docs-coverage.mjs b/scripts/check-docs-coverage.mjs index 45fa188d6..b1ea80962 100644 --- a/scripts/check-docs-coverage.mjs +++ b/scripts/check-docs-coverage.mjs @@ -121,7 +121,7 @@ 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"); @@ -133,7 +133,8 @@ function checkMarkdownLinks(dir) { if (!rawTarget) continue; if (/^[a-z][a-z0-9+.-]*:/i.test(rawTarget)) continue; - const [targetPath, rawAnchor] = rawTarget.split("#", 2); + const [rawPath, rawAnchor] = rawTarget.split("#", 2); + const targetPath = decodeMarkdownTarget(rawPath); if (/^(url|path|file)$/i.test(targetPath)) continue; const resolved = targetPath ? path.resolve(path.dirname(file), targetPath) : file; @@ -142,9 +143,10 @@ function checkMarkdownLinks(dir) { continue; } - if (rawAnchor && resolved.endsWith(".md")) { + if (rawAnchor && resolved.toLowerCase().endsWith(".md")) { + const anchor = decodeMarkdownTarget(rawAnchor); const targetHeadings = resolved === file ? headings : headingAnchors(fs.readFileSync(resolved, "utf8")); - if (!targetHeadings.has(rawAnchor)) { + if (!targetHeadings.has(anchor)) { broken.push(`${path.relative(root, file)} -> ${rawTarget}`); } } @@ -158,9 +160,17 @@ function splitMarkdownTarget(rawTarget) { return targetWithoutTitle.replace(/^<|>$/g, ""); } +function decodeMarkdownTarget(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + export function headingAnchors(markdown) { const anchors = new Set(); - const seen = new Map(); + const occurrences = new Map(); let fence = null; for (const rawLine of markdown.split("\n")) { const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; @@ -184,46 +194,29 @@ export function headingAnchors(markdown) { const match = line.match(/^(#{1,6})\s+(.*)$/); if (!match) continue; - const base = slugifyHeading(match[2]); + const heading = match[2].replace(/\s+#+\s*$/, "").trim(); + const base = slugifyHeading(heading); if (!base) continue; - const count = seen.get(base) || 0; - seen.set(base, count + 1); - anchors.add(count === 0 ? base : `${base}-${count}`); + 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) { - let out = ""; - let inTag = false; - let lastDash = false; - for (const char of text.toLowerCase()) { - if (char === "`") { - continue; - } - if (char === "<") { - inTag = true; - continue; - } - if (char === ">") { - inTag = false; - continue; - } - if (inTag) { - continue; - } - const code = char.charCodeAt(0); - const ok = (code >= 97 && code <= 122) || (code >= 48 && code <= 57); - if (ok) { - out += char; - lastDash = false; - } else if (!lastDash) { - out += "-"; - lastDash = true; - } - } - return out.replace(/^-+|-+$/g, ""); + return text + .replace(/<[^>]*>/g, "") + .replace(/`/g, "") + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, "") + .replace(/ /g, "-"); } function allMarkdown(dir) { diff --git a/scripts/check-docs-coverage.test.mjs b/scripts/check-docs-coverage.test.mjs index d7a729f5c..c858ad7fb 100644 --- a/scripts/check-docs-coverage.test.mjs +++ b/scripts/check-docs-coverage.test.mjs @@ -1,7 +1,10 @@ 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 { headingAnchors } from "./check-docs-coverage.mjs"; +import { checkMarkdownLinks, headingAnchors } from "./check-docs-coverage.mjs"; test("headingAnchors ignores headings inside fenced code blocks", () => { const anchors = headingAnchors(`# Real Heading @@ -23,3 +26,43 @@ test("headingAnchors ignores headings inside fenced code blocks", () => { 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$/); +});