File size: 7,050 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
#!/usr/bin/env node
/**
 * OmniRoute β€” internal documentation link checker.
 *
 * Scans every Markdown file under `docs/` (excluding mirrored translations,
 * screenshots, exported diagrams, and superpowers plans) for relative or
 * project-rooted internal links and verifies the referenced files exist on
 * disk. External URLs (http/https/mailto), telephone links, and pure
 * anchor-only links (#section) are ignored.
 *
 * Supported syntaxes:
 *   - Markdown links:     [label](path "optional title")
 *   - Reference links:    [label]: path "optional title"
 *   - HTML anchors:       <a href="path">
 *   - HTML image refs:    <img src="path">
 *
 * Usage:
 *   node scripts/check/check-doc-links.mjs            # strict (CI gate)
 *   node scripts/check/check-doc-links.mjs --report   # human report, exit 0
 *   node scripts/check/check-doc-links.mjs --json     # JSON to stdout
 *
 * Exit codes:
 *   0  ok (or --report mode)
 *   1  one or more broken internal links detected
 */

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const DOCS_ROOT = path.join(REPO_ROOT, "docs");

const EXCLUDE_PREFIXES = [
  path.join(DOCS_ROOT, "i18n") + path.sep,
  path.join(DOCS_ROOT, "screenshots") + path.sep,
  path.join(DOCS_ROOT, "superpowers") + path.sep,
  path.join(DOCS_ROOT, "diagrams", "exported") + path.sep,
];

function parseArgs(argv) {
  const opts = { report: false, json: false };
  for (const arg of argv.slice(2)) {
    if (arg === "--report") opts.report = true;
    else if (arg === "--json") opts.json = true;
    else if (arg === "--help" || arg === "-h") {
      console.log(
        [
          "Usage: node scripts/check/check-doc-links.mjs [options]",
          "",
          "  --report   Print findings and exit 0 regardless",
          "  --json     Emit JSON report to stdout",
        ].join("\n")
      );
      process.exit(0);
    }
  }
  return opts;
}

function walkDocs(dir, out) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      const prefixed = full + path.sep;
      if (EXCLUDE_PREFIXES.some((p) => prefixed.startsWith(p))) continue;
      walkDocs(full, out);
    } else if (entry.isFile() && full.endsWith(".md")) {
      if (EXCLUDE_PREFIXES.some((p) => full.startsWith(p))) continue;
      out.push(full);
    }
  }
}

function isExternal(target) {
  return (
    /^[a-z][a-z0-9+.-]*:/i.test(target) || // http:, https:, mailto:, tel:, data:, ftp: ...
    target.startsWith("//")
  );
}

function stripFragmentAndQuery(target) {
  let value = target;
  const hashAt = value.indexOf("#");
  if (hashAt !== -1) value = value.slice(0, hashAt);
  const queryAt = value.indexOf("?");
  if (queryAt !== -1) value = value.slice(0, queryAt);
  return value;
}

function extractLinks(content) {
  const links = [];
  // Strip fenced code blocks to avoid false positives.
  const sanitized = content.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
  const lines = sanitized.split(/\r?\n/);

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const lineNumber = i + 1;

    // Markdown inline links: [text](target) β€” supports nested parens minimally.
    const inlineRe = /(!?)\[(?:[^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)/g;
    let match;
    while ((match = inlineRe.exec(line)) !== null) {
      const raw = match[2].replace(/\s+"[^"]*"$/, "").trim();
      if (raw) links.push({ target: raw, line: lineNumber });
    }

    // Reference-style links: [label]: target  ("optional title")
    const refRe = /^\s{0,3}\[[^\]]+\]:\s+([^\s]+)(?:\s+"[^"]*")?\s*$/;
    const refMatch = line.match(refRe);
    if (refMatch) links.push({ target: refMatch[1], line: lineNumber });

    // HTML href / src
    const htmlRe = /\b(?:href|src)\s*=\s*"([^"]+)"/g;
    while ((match = htmlRe.exec(line)) !== null) {
      links.push({ target: match[1], line: lineNumber });
    }
  }

  return links;
}

function resolveTarget(sourceFile, target) {
  // /docs/foo.md, /foo, /reference/ENVIRONMENT.md β†’ project-rooted.
  if (target.startsWith("/")) {
    return path.join(REPO_ROOT, target.replace(/^\/+/, ""));
  }
  // Otherwise resolve against the source file's directory.
  return path.resolve(path.dirname(sourceFile), target);
}

function probeExists(absPath) {
  if (fs.existsSync(absPath)) return true;
  // Allow links omitting `.md` (some doc viewers do this).
  if (!path.extname(absPath) && fs.existsSync(`${absPath}.md`)) return true;
  // Allow directory links resolving to an index/README.
  if (fs.existsSync(path.join(absPath, "README.md"))) return true;
  if (fs.existsSync(path.join(absPath, "index.md"))) return true;
  return false;
}

function main() {
  const opts = parseArgs(process.argv);

  if (!fs.existsSync(DOCS_ROOT)) {
    console.error("[doc-links] FAIL β€” docs/ directory not found");
    process.exit(1);
  }

  const files = [];
  walkDocs(DOCS_ROOT, files);

  /** @type {Array<{source:string, line:number, target:string, reason:string}>} */
  const broken = [];
  let checkedLinks = 0;

  for (const file of files) {
    const content = fs.readFileSync(file, "utf8");
    const links = extractLinks(content);
    for (const { target, line } of links) {
      if (!target) continue;
      if (target.startsWith("#")) continue; // anchor-only
      if (isExternal(target)) continue;
      const clean = stripFragmentAndQuery(target);
      if (!clean) continue; // e.g. "?query" alone β€” ignore
      checkedLinks++;
      const abs = resolveTarget(file, clean);
      if (!probeExists(abs)) {
        broken.push({
          source: path.relative(REPO_ROOT, file),
          line,
          target,
          reason: "missing",
        });
      }
    }
  }

  if (opts.json) {
    process.stdout.write(
      JSON.stringify(
        {
          ok: broken.length === 0,
          scannedFiles: files.length,
          checkedLinks,
          broken,
        },
        null,
        2
      ) + "\n"
    );
    process.exit(broken.length && !opts.report ? 1 : 0);
  }

  console.log(`[doc-links] scanned ${files.length} docs, checked ${checkedLinks} internal links`);

  if (broken.length === 0) {
    console.log("[doc-links] PASS β€” no broken internal links");
    process.exit(0);
  }

  // Group by source for readability.
  const bySource = new Map();
  for (const entry of broken) {
    if (!bySource.has(entry.source)) bySource.set(entry.source, []);
    bySource.get(entry.source).push(entry);
  }

  console.log(`[doc-links] FAIL β€” ${broken.length} broken link(s) in ${bySource.size} file(s):`);
  for (const [source, entries] of bySource) {
    console.log(`\n  ${source}`);
    for (const entry of entries) {
      console.log(`    line ${entry.line}: ${entry.target}`);
    }
  }

  process.exit(opts.report ? 0 : 1);
}

main();