Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
179 changes: 133 additions & 46 deletions scripts/check-docs-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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 })
Expand Down
68 changes: 68 additions & 0 deletions scripts/check-docs-coverage.test.mjs
Original file line number Diff line number Diff line change
@@ -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$/);
});