create-zudo-circuit-doc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -0
  4. package/bin/create-zudo-circuit-doc.js +6 -0
  5. package/dist/args.d.ts +21 -0
  6. package/dist/args.js +71 -0
  7. package/dist/cli.d.ts +27 -0
  8. package/dist/cli.js +101 -0
  9. package/dist/errors.d.ts +4 -0
  10. package/dist/errors.js +7 -0
  11. package/dist/git.d.ts +11 -0
  12. package/dist/git.js +69 -0
  13. package/dist/help.d.ts +2 -0
  14. package/dist/help.js +21 -0
  15. package/dist/install.d.ts +3 -0
  16. package/dist/install.js +17 -0
  17. package/dist/next-steps.d.ts +10 -0
  18. package/dist/next-steps.js +24 -0
  19. package/dist/plan.d.ts +17 -0
  20. package/dist/plan.js +59 -0
  21. package/dist/prompt.d.ts +3 -0
  22. package/dist/prompt.js +12 -0
  23. package/dist/scaffold.d.ts +22 -0
  24. package/dist/scaffold.js +187 -0
  25. package/dist/shell-quote.d.ts +2 -0
  26. package/dist/shell-quote.js +7 -0
  27. package/dist/validate.d.ts +18 -0
  28. package/dist/validate.js +85 -0
  29. package/dist/version.d.ts +6 -0
  30. package/dist/version.js +13 -0
  31. package/package.json +49 -0
  32. package/templates/default/.claude/skills/circuit-spec-integration/SKILL.md +22 -0
  33. package/templates/default/.claude/skills/circuit-spec-integration/references/rules.json +4 -0
  34. package/templates/default/.claude/skills/component-spec-audit/SKILL.md +36 -0
  35. package/templates/default/.claude/skills/component-spec-audit/references/contract.md +21 -0
  36. package/templates/default/.claude/skills/component-spec-audit/references/direct-routing.json +5 -0
  37. package/templates/default/.claude/skills/component-spec-audit/references/external-vendor-qualifiers.json +4 -0
  38. package/templates/default/.claude/skills/component-spec-audit/references/inventory.json +11 -0
  39. package/templates/default/.claude/skills/component-spec-audit/references/new-component-workflow.md +113 -0
  40. package/templates/default/AGENTS.md +7 -0
  41. package/templates/default/CLAUDE.md +7 -0
  42. package/templates/default/README.md +49 -0
  43. package/templates/default/ZUDO_DEPS_PINS.md +48 -0
  44. package/templates/default/_gitignore +25 -0
  45. package/templates/default/circuit/WORKFLOW.md +361 -0
  46. package/templates/default/circuit/agent-task-examples.md +178 -0
  47. package/templates/default/circuit/checks/README.md +37 -0
  48. package/templates/default/circuit/generated/preflight.json +625 -0
  49. package/templates/default/circuit/publication/assets.json +9 -0
  50. package/templates/default/circuit/publication/selection.json +13 -0
  51. package/templates/default/circuit/templates/README.md +33 -0
  52. package/templates/default/circuit/templates/cad-asset-receipt.json +58 -0
  53. package/templates/default/circuit/templates/cad-asset-receipt.md +33 -0
  54. package/templates/default/circuit/templates/project-docs/architecture/interfaces.mdx +52 -0
  55. package/templates/default/circuit/templates/project-docs/architecture/overview.mdx +55 -0
  56. package/templates/default/circuit/templates/project-docs/decisions/decision.mdx +66 -0
  57. package/templates/default/circuit/templates/project-docs/decisions/sourcing.mdx +57 -0
  58. package/templates/default/circuit/templates/project-docs/project/change-impact.mdx +61 -0
  59. package/templates/default/circuit/templates/project-docs/project/index.mdx +62 -0
  60. package/templates/default/circuit/templates/project-docs/project/next-actions.mdx +58 -0
  61. package/templates/default/circuit/templates/project-docs/project/task-request.mdx +56 -0
  62. package/templates/default/circuit/templates/project-docs/research/component-candidate.mdx +60 -0
  63. package/templates/default/circuit/templates/project-docs/verification/bring-up.mdx +55 -0
  64. package/templates/default/circuit.config.ts +36 -0
  65. package/templates/default/doc/package.json +37 -0
  66. package/templates/default/doc/pages/docs/[[...slug]].tsx +68 -0
  67. package/templates/default/doc/pages/index.tsx +6 -0
  68. package/templates/default/doc/pages/lib/_circuit-doc-islands.ts +4 -0
  69. package/templates/default/doc/public/favicon-16x16.png +0 -0
  70. package/templates/default/doc/public/favicon-32x32.png +0 -0
  71. package/templates/default/doc/public/favicon.ico +0 -0
  72. package/templates/default/doc/public/favicon.svg +4 -0
  73. package/templates/default/doc/scripts/check-links.js +969 -0
  74. package/templates/default/doc/src/chrome-bindings.tsx +11 -0
  75. package/templates/default/doc/src/content/docs/architecture/index.mdx +11 -0
  76. package/templates/default/doc/src/content/docs/architecture/overview.mdx +55 -0
  77. package/templates/default/doc/src/content/docs/components/catalog/index.mdx +12 -0
  78. package/templates/default/doc/src/content/docs/components/index.mdx +49 -0
  79. package/templates/default/doc/src/content/docs/components/integration/index.mdx +34 -0
  80. package/templates/default/doc/src/content/docs/components/records/index.mdx +14 -0
  81. package/templates/default/doc/src/content/docs/decisions/index.mdx +9 -0
  82. package/templates/default/doc/src/content/docs/project/how-we-work.mdx +67 -0
  83. package/templates/default/doc/src/content/docs/project/index.mdx +60 -0
  84. package/templates/default/doc/src/content/docs/project/next-actions.mdx +58 -0
  85. package/templates/default/doc/src/content/docs/research/index.mdx +9 -0
  86. package/templates/default/doc/src/content/docs/verification/index.mdx +9 -0
  87. package/templates/default/doc/src/styles/global.css +31 -0
  88. package/templates/default/doc/tsconfig.json +13 -0
  89. package/templates/default/doc/zfb.config.ts +78 -0
  90. package/templates/default/package.json +23 -0
  91. package/templates/default/pnpm-workspace.yaml +9 -0
@@ -0,0 +1,969 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Check links in a generated zudo-doc project.
5
+ *
6
+ * The source scan is intentionally useful before a build: generated projects
7
+ * do not need a dist/ directory for anchor validation. When dist/ exists, its
8
+ * HTML is checked as an additional pass.
9
+ */
10
+
11
+ import { access, readFile, readdir, stat } from "node:fs/promises";
12
+ import { dirname, extname, join, relative, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { extractAllHeadingIds } from "@takazudo/zudo-doc/extract-headings";
15
+
16
+ const CLI_USAGE = `Usage: pnpm check:links -- [options]
17
+
18
+ Options:
19
+ -h, --help Show this help
20
+ --strict-broken Fail when broken links remain after the allowlist
21
+ --strict-absolute Fail when absolute MDX links remain after the allowlist
22
+ --strict-anchors Fail when invalid anchors remain after the allowlist
23
+ --strict-trailing Fail when trailing-slash warnings remain after the allowlist
24
+ --allowlist=PATH Exclude exact <file>:<line>:<href> entries from failure counts`;
25
+
26
+ class CliArgumentError extends Error {}
27
+
28
+ function parseCliArgs(argv) {
29
+ const result = {
30
+ help: false,
31
+ strictBroken: false,
32
+ strictAbsolute: false,
33
+ strictAnchors: false,
34
+ strictTrailing: false,
35
+ allowlistPath: null,
36
+ };
37
+
38
+ for (const arg of argv) {
39
+ if (arg === "--") continue;
40
+ if (arg === "-h" || arg === "--help") result.help = true;
41
+ else if (arg === "--strict-broken") result.strictBroken = true;
42
+ else if (arg === "--strict-absolute") result.strictAbsolute = true;
43
+ else if (arg === "--strict-anchors") result.strictAnchors = true;
44
+ else if (arg === "--strict-trailing") result.strictTrailing = true;
45
+ else if (arg.startsWith("--allowlist=")) {
46
+ result.allowlistPath = arg.slice("--allowlist=".length);
47
+ if (!result.allowlistPath) {
48
+ throw new CliArgumentError("--allowlist requires a non-empty path");
49
+ }
50
+ } else {
51
+ throw new CliArgumentError(`Unknown option: ${arg}\n\n${CLI_USAGE}`);
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ async function fileExists(filePath) {
58
+ try {
59
+ await access(filePath);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async function isDirectory(dirPath) {
67
+ try {
68
+ return (await stat(dirPath)).isDirectory();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ export async function collectFiles(dir, extensions) {
75
+ const files = [];
76
+ async function walk(current) {
77
+ let entries;
78
+ try {
79
+ entries = await readdir(current, { withFileTypes: true });
80
+ } catch {
81
+ return;
82
+ }
83
+ for (const entry of entries) {
84
+ const full = join(current, entry.name);
85
+ if (entry.isDirectory()) await walk(full);
86
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) {
87
+ files.push(full);
88
+ }
89
+ }
90
+ }
91
+ await walk(dir);
92
+ return files.sort();
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // zfb.config.ts literal extraction
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function skipTrivia(source, index, end = source.length) {
100
+ let cursor = index;
101
+ while (cursor < end) {
102
+ if (/\s/.test(source[cursor])) {
103
+ cursor += 1;
104
+ continue;
105
+ }
106
+ if (source.startsWith("//", cursor)) {
107
+ const newline = source.indexOf("\n", cursor + 2);
108
+ cursor = newline === -1 || newline >= end ? end : newline + 1;
109
+ continue;
110
+ }
111
+ if (source.startsWith("/*", cursor)) {
112
+ const close = source.indexOf("*/", cursor + 2);
113
+ if (close === -1 || close + 2 > end) {
114
+ throw new Error("unterminated block comment");
115
+ }
116
+ cursor = close + 2;
117
+ continue;
118
+ }
119
+ break;
120
+ }
121
+ return cursor;
122
+ }
123
+
124
+ function readStringEnd(source, start, end = source.length) {
125
+ const quote = source[start];
126
+ if (quote !== "\"" && quote !== "'") return null;
127
+ let cursor = start + 1;
128
+ while (cursor < end) {
129
+ if (source[cursor] === "\\") {
130
+ cursor += 2;
131
+ continue;
132
+ }
133
+ if (source[cursor] === quote) return cursor + 1;
134
+ cursor += 1;
135
+ }
136
+ throw new Error("unterminated string literal");
137
+ }
138
+
139
+ function readStringValue(source, start, end, fieldName) {
140
+ const valueStart = skipTrivia(source, start, end);
141
+ const valueEnd = readStringEnd(source, valueStart, end);
142
+ if (valueEnd === null || skipTrivia(source, valueEnd, end) !== end) {
143
+ throw new Error(
144
+ `zfb.config.ts field ${fieldName} must be a literal string (dynamic expressions are not supported)`,
145
+ );
146
+ }
147
+ const raw = source.slice(valueStart, valueEnd);
148
+ try {
149
+ if (raw[0] === '"') return JSON.parse(raw);
150
+ // Generated configs use JSON strings, but accepting ordinary single-
151
+ // quoted TypeScript literals makes the extractor useful for hand edits.
152
+ let result = "";
153
+ for (let i = 1; i < raw.length - 1; i += 1) {
154
+ if (raw[i] !== "\\") {
155
+ result += raw[i];
156
+ continue;
157
+ }
158
+ const escaped = raw[++i];
159
+ const escapes = {
160
+ n: "\n",
161
+ r: "\r",
162
+ t: "\t",
163
+ b: "\b",
164
+ f: "\f",
165
+ v: "\v",
166
+ "0": "\0",
167
+ "\\": "\\",
168
+ "'": "'",
169
+ '"': '"',
170
+ };
171
+ if (escaped === "u") {
172
+ const hex = raw.slice(i + 1, i + 5);
173
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error("invalid unicode escape");
174
+ result += String.fromCharCode(Number.parseInt(hex, 16));
175
+ i += 4;
176
+ } else if (escaped === "x") {
177
+ const hex = raw.slice(i + 1, i + 3);
178
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) throw new Error("invalid hex escape");
179
+ result += String.fromCharCode(Number.parseInt(hex, 16));
180
+ i += 2;
181
+ } else if (escaped in escapes) result += escapes[escaped];
182
+ else throw new Error(`unsupported escape \\${escaped}`);
183
+ }
184
+ return result;
185
+ } catch (error) {
186
+ throw new Error(`zfb.config.ts field ${fieldName} has an invalid string literal: ${error.message}`);
187
+ }
188
+ }
189
+
190
+ function matchingDelimiter(source, start, end = source.length) {
191
+ const opening = source[start];
192
+ const pairs = { "{": "}", "[": "]", "(": ")" };
193
+ if (!(opening in pairs)) throw new Error(`expected an object/array/call at offset ${start}`);
194
+ const stack = [pairs[opening]];
195
+ let cursor = start + 1;
196
+ while (cursor < end) {
197
+ if (source[cursor] === "\"" || source[cursor] === "'") {
198
+ cursor = readStringEnd(source, cursor, end);
199
+ continue;
200
+ }
201
+ if (source.startsWith("//", cursor)) {
202
+ const newline = source.indexOf("\n", cursor + 2);
203
+ cursor = newline === -1 || newline >= end ? end : newline + 1;
204
+ continue;
205
+ }
206
+ if (source.startsWith("/*", cursor)) {
207
+ const close = source.indexOf("*/", cursor + 2);
208
+ if (close === -1 || close + 2 > end) throw new Error("unterminated block comment");
209
+ cursor = close + 2;
210
+ continue;
211
+ }
212
+ if (source[cursor] in pairs) stack.push(pairs[source[cursor]]);
213
+ else if (source[cursor] === stack.at(-1)) stack.pop();
214
+ else if (source[cursor] === "}" || source[cursor] === "]" || source[cursor] === ")") {
215
+ throw new Error(`unexpected delimiter ${source[cursor]} in zfb.config.ts`);
216
+ }
217
+ if (stack.length === 0) return cursor;
218
+ cursor += 1;
219
+ }
220
+ throw new Error("unterminated literal in zfb.config.ts");
221
+ }
222
+
223
+ function valueEndAtComma(source, start, end) {
224
+ const stack = [];
225
+ let cursor = start;
226
+ while (cursor < end) {
227
+ if (source[cursor] === "\"" || source[cursor] === "'") {
228
+ cursor = readStringEnd(source, cursor, end);
229
+ continue;
230
+ }
231
+ if (source.startsWith("//", cursor)) {
232
+ const newline = source.indexOf("\n", cursor + 2);
233
+ cursor = newline === -1 || newline >= end ? end : newline + 1;
234
+ continue;
235
+ }
236
+ if (source.startsWith("/*", cursor)) {
237
+ const close = source.indexOf("*/", cursor + 2);
238
+ if (close === -1 || close + 2 > end) throw new Error("unterminated block comment");
239
+ cursor = close + 2;
240
+ continue;
241
+ }
242
+ if (source[cursor] === "{" || source[cursor] === "[" || source[cursor] === "(") {
243
+ stack.push(source[cursor]);
244
+ cursor += 1;
245
+ continue;
246
+ }
247
+ if (source[cursor] === "}" || source[cursor] === "]" || source[cursor] === ")") {
248
+ if (stack.length === 0) return cursor;
249
+ stack.pop();
250
+ cursor += 1;
251
+ continue;
252
+ }
253
+ if (source[cursor] === "," && stack.length === 0) return cursor;
254
+ cursor += 1;
255
+ }
256
+ return end;
257
+ }
258
+
259
+ function parseObjectEntries(source, open, close, context) {
260
+ const entries = new Map();
261
+ let cursor = open + 1;
262
+ while (true) {
263
+ cursor = skipTrivia(source, cursor, close);
264
+ if (cursor >= close) break;
265
+ if (source.startsWith("...", cursor)) {
266
+ throw new Error(
267
+ `zfb.config.ts ${context} contains a spread; config fields must be literal values`,
268
+ );
269
+ }
270
+ let key;
271
+ if (source[cursor] === "\"" || source[cursor] === "'") {
272
+ const keyEnd = readStringEnd(source, cursor, close);
273
+ key = readStringValue(source, cursor, keyEnd, `${context} key`);
274
+ cursor = keyEnd;
275
+ } else {
276
+ const match = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(source.slice(cursor, close));
277
+ if (!match) throw new Error(`zfb.config.ts ${context} has an invalid property name`);
278
+ key = match[0];
279
+ cursor += key.length;
280
+ }
281
+ cursor = skipTrivia(source, cursor, close);
282
+ if (source[cursor] !== ":") {
283
+ const literalKind = key === "base" || key === "docsDir" ? "literal string" : "literal value";
284
+ throw new Error(`zfb.config.ts field ${key} must be a ${literalKind} (dynamic expressions are not supported)`);
285
+ }
286
+ const valueStart = cursor + 1;
287
+ const comma = valueEndAtComma(source, valueStart, close);
288
+ const previous = entries.get(key);
289
+ if (previous !== undefined) throw new Error(`zfb.config.ts declares ${context}.${key} more than once`);
290
+ entries.set(key, { start: valueStart, end: comma });
291
+ cursor = comma;
292
+ if (cursor < close && source[cursor] === ",") cursor += 1;
293
+ else if (cursor < close) throw new Error(`zfb.config.ts ${context} has an invalid separator`);
294
+ }
295
+ return entries;
296
+ }
297
+
298
+ function readLiteralBoolean(source, start, end, fieldName) {
299
+ const valueStart = skipTrivia(source, start, end);
300
+ for (const [literal, value] of [["true", true], ["false", false]]) {
301
+ const literalEnd = valueStart + literal.length;
302
+ if (source.slice(valueStart, literalEnd) === literal && skipTrivia(source, literalEnd, end) === end) {
303
+ return value;
304
+ }
305
+ }
306
+ throw new Error(
307
+ `zfb.config.ts field ${fieldName} must be the literal true or false (dynamic expressions are not supported)`,
308
+ );
309
+ }
310
+
311
+ function readObjectValue(source, start, end, fieldName) {
312
+ const valueStart = skipTrivia(source, start, end);
313
+ if (source[valueStart] !== "{") {
314
+ throw new Error(`zfb.config.ts field ${fieldName} must be a literal object`);
315
+ }
316
+ const valueClose = matchingDelimiter(source, valueStart, end);
317
+ if (skipTrivia(source, valueClose + 1, end) !== end) {
318
+ throw new Error(`zfb.config.ts field ${fieldName} must be a literal object (dynamic expressions are not supported)`);
319
+ }
320
+ return { open: valueStart, close: valueClose };
321
+ }
322
+
323
+ function findZudoDocCall(source) {
324
+ let cursor = 0;
325
+ let found = null;
326
+ while (cursor < source.length) {
327
+ if (source[cursor] === "\"" || source[cursor] === "'") {
328
+ cursor = readStringEnd(source, cursor);
329
+ continue;
330
+ }
331
+ if (source.startsWith("//", cursor)) {
332
+ const newline = source.indexOf("\n", cursor + 2);
333
+ cursor = newline === -1 ? source.length : newline + 1;
334
+ continue;
335
+ }
336
+ if (source.startsWith("/*", cursor)) {
337
+ const close = source.indexOf("*/", cursor + 2);
338
+ if (close === -1) throw new Error("unterminated block comment in zfb.config.ts");
339
+ cursor = close + 2;
340
+ continue;
341
+ }
342
+ if (source.startsWith("zudoDoc", cursor) && !/[A-Za-z0-9_$]/.test(source[cursor - 1] ?? "")) {
343
+ const afterName = cursor + "zudoDoc".length;
344
+ if (!/[A-Za-z0-9_$]/.test(source[afterName] ?? "")) {
345
+ const openParen = skipTrivia(source, afterName);
346
+ if (source[openParen] === "(") {
347
+ if (found !== null) throw new Error("zfb.config.ts must contain exactly one zudoDoc({...}) call");
348
+ found = openParen;
349
+ cursor = openParen + 1;
350
+ continue;
351
+ }
352
+ }
353
+ }
354
+ cursor += 1;
355
+ }
356
+ return found;
357
+ }
358
+
359
+ export async function parseZfbConfig(configPath) {
360
+ const source = await readFile(configPath, "utf-8");
361
+ const openParen = findZudoDocCall(source);
362
+ if (openParen === null) throw new Error("zfb.config.ts does not contain a zudoDoc({...}) call");
363
+ const closeParen = matchingDelimiter(source, openParen);
364
+ const objectOpen = skipTrivia(source, openParen + 1, closeParen);
365
+ if (source[objectOpen] !== "{") {
366
+ throw new Error("zfb.config.ts zudoDoc() argument must be a literal object (imports and spreads are not supported)");
367
+ }
368
+ const objectClose = matchingDelimiter(source, objectOpen, closeParen);
369
+ if (skipTrivia(source, objectClose + 1, closeParen) !== closeParen) {
370
+ throw new Error("zfb.config.ts zudoDoc() accepts one literal object argument");
371
+ }
372
+
373
+ const entries = parseObjectEntries(source, objectOpen, objectClose, "zudoDoc({...})");
374
+ const result = {
375
+ basePath: "/",
376
+ trailingSlash: false,
377
+ docsDir: "src/content/docs",
378
+ localeDirs: [],
379
+ localeKeys: [],
380
+ };
381
+
382
+ const base = entries.get("base");
383
+ if (base) result.basePath = readStringValue(source, base.start, base.end, "base");
384
+ const trailing = entries.get("trailingSlash");
385
+ if (trailing) result.trailingSlash = readLiteralBoolean(source, trailing.start, trailing.end, "trailingSlash");
386
+ const docsDir = entries.get("docsDir");
387
+ if (docsDir) result.docsDir = readStringValue(source, docsDir.start, docsDir.end, "docsDir");
388
+
389
+ const locales = entries.get("locales");
390
+ if (locales) {
391
+ const localeObject = readObjectValue(source, locales.start, locales.end, "locales");
392
+ const localeEntries = parseObjectEntries(source, localeObject.open, localeObject.close, "locales");
393
+ for (const [key, value] of localeEntries) {
394
+ const localeConfig = readObjectValue(source, value.start, value.end, `locales.${key}`);
395
+ const localeFields = parseObjectEntries(source, localeConfig.open, localeConfig.close, `locales.${key}`);
396
+ const dir = localeFields.get("dir");
397
+ if (!dir) {
398
+ throw new Error(`zfb.config.ts field locales.${key}.dir is required for link checking`);
399
+ }
400
+ result.localeKeys.push(key);
401
+ result.localeDirs.push(readStringValue(source, dir.start, dir.end, `locales.${key}.dir`));
402
+ }
403
+ }
404
+ return result;
405
+ }
406
+
407
+ export async function parseBasePath(configPath) {
408
+ return (await parseZfbConfig(configPath)).basePath;
409
+ }
410
+
411
+ export async function parseTrailingSlash(configPath) {
412
+ return (await parseZfbConfig(configPath)).trailingSlash;
413
+ }
414
+
415
+ export async function parseContentDirs(configPath) {
416
+ const config = await parseZfbConfig(configPath);
417
+ return {
418
+ docsDir: config.docsDir,
419
+ localeDirs: config.localeDirs,
420
+ localeKeys: config.localeKeys,
421
+ };
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Shared link and anchor logic
426
+ // ---------------------------------------------------------------------------
427
+
428
+ const HTML_NAMED_CHARACTER_REFERENCES = {
429
+ amp: "&",
430
+ apos: "'",
431
+ gt: ">",
432
+ lt: "<",
433
+ quot: '"',
434
+ };
435
+
436
+ function decodeHtmlAttributeValue(value) {
437
+ return value.replace(
438
+ /&(?:#([0-9]+)|#x([0-9a-f]+)|(amp|apos|gt|lt|quot));/gi,
439
+ (_reference, decimal, hexadecimal, named) => {
440
+ if (named !== undefined) return HTML_NAMED_CHARACTER_REFERENCES[named.toLowerCase()];
441
+ const codePoint = Number.parseInt(hexadecimal ?? decimal, hexadecimal === undefined ? 10 : 16);
442
+ if (codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return "\uFFFD";
443
+ return String.fromCodePoint(codePoint);
444
+ },
445
+ );
446
+ }
447
+
448
+ // Attribute-scan grammar shared by the anchor and id scans below. A quoted
449
+ // attribute value may legally contain ">" (title="a > b"), so bounding an
450
+ // attribute scan with [^>] silently drops the whole tag: a lost id is a noisy
451
+ // false STRICT FAIL, while a lost href means the link is never checked at all
452
+ // (#4046). This run crosses ">" only inside quotes, and a decoy `href=`/`id=`
453
+ // written as text inside another attribute's value stays unreachable because a
454
+ // quoted span is consumed whole. Each alternative starts with a distinct
455
+ // character, so the repetition backtracks linearly.
456
+ const HTML_ATTRIBUTE_RUN = /(?:"[^"]*"|'[^']*'|[^>"'])/.source;
457
+ const HTML_ATTRIBUTE_VALUE = /(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`\\]+))/.source;
458
+
459
+ // Single shared anchor scan. Its one consumer, `classifyHtmlAnchorHrefs`,
460
+ // splits these matches into disjoint buckets, so the grammar and the
461
+ // incremental line counting live here once and every bucket sees a fix to the
462
+ // anchor regex.
463
+ function* iterateHtmlAnchorHrefs(html) {
464
+ const regex = new RegExp(
465
+ `<a(?=\\s)${HTML_ATTRIBUTE_RUN}*?\\shref\\s*=\\s*${HTML_ATTRIBUTE_VALUE}${HTML_ATTRIBUTE_RUN}*>`,
466
+ "gi",
467
+ );
468
+ let match;
469
+ let lastIndex = 0;
470
+ let line = 1;
471
+ while ((match = regex.exec(html)) !== null) {
472
+ for (let i = lastIndex; i < match.index; i += 1) if (html[i] === "\n") line += 1;
473
+ lastIndex = match.index;
474
+ yield { href: decodeHtmlAttributeValue(match[1] ?? match[2] ?? match[3]), line };
475
+ }
476
+ }
477
+
478
+ // One anchor pass, two disjoint buckets: internal links to resolve, and the
479
+ // protocol-relative hrefs that are external for resolution purposes but worth
480
+ // reporting informationally (see #3921/#3930). The dist walk calls this once
481
+ // per page; `extractHtmlLinks` / `extractProtocolRelativeHtmlLinks` below are
482
+ // single-bucket views for callers that want only one of the two.
483
+ export function classifyHtmlAnchorHrefs(html) {
484
+ const links = [];
485
+ const protocolRelative = [];
486
+ for (const { href, line } of iterateHtmlAnchorHrefs(html)) {
487
+ if (/^\/\//.test(href)) {
488
+ protocolRelative.push({ href, line });
489
+ continue;
490
+ }
491
+ if (/^(?:https?:|mailto:|javascript:|data:|tel:)/i.test(href)) continue;
492
+ links.push({ href, line });
493
+ }
494
+ return { links, protocolRelative };
495
+ }
496
+
497
+ export function extractHtmlLinks(html) {
498
+ return classifyHtmlAnchorHrefs(html).links;
499
+ }
500
+
501
+ export function extractProtocolRelativeHtmlLinks(html) {
502
+ return classifyHtmlAnchorHrefs(html).protocolRelative;
503
+ }
504
+
505
+ export function extractHtmlIds(html) {
506
+ const ids = [];
507
+ const regex = new RegExp(
508
+ `<[A-Za-z]${HTML_ATTRIBUTE_RUN}*?\\sid\\s*=\\s*${HTML_ATTRIBUTE_VALUE}${HTML_ATTRIBUTE_RUN}*>`,
509
+ "gi",
510
+ );
511
+ let match;
512
+ while ((match = regex.exec(html)) !== null) {
513
+ ids.push(decodeHtmlAttributeValue(match[1] ?? match[2] ?? match[3]));
514
+ }
515
+ return ids;
516
+ }
517
+
518
+ function safeDecodePath(path) {
519
+ try {
520
+ return decodeURIComponent(path);
521
+ } catch {
522
+ return path;
523
+ }
524
+ }
525
+
526
+ function parseHref(href) {
527
+ const hashAt = href.indexOf("#");
528
+ const beforeFragment = hashAt === -1 ? href : href.slice(0, hashAt);
529
+ const queryAt = beforeFragment.indexOf("?");
530
+ const rawPath = queryAt === -1 ? beforeFragment : beforeFragment.slice(0, queryAt);
531
+ const rawFragment = hashAt === -1 ? null : href.slice(hashAt + 1);
532
+ if (rawFragment === null) return { path: safeDecodePath(rawPath), rawPath, fragment: null, fragmentError: null };
533
+ if (rawFragment === "") return { path: safeDecodePath(rawPath), rawPath, fragment: "", fragmentError: "empty fragment" };
534
+ try {
535
+ return { path: safeDecodePath(rawPath), rawPath, fragment: decodeURIComponent(rawFragment), fragmentError: null };
536
+ } catch {
537
+ return { path: safeDecodePath(rawPath), rawPath, fragment: rawFragment, fragmentError: "malformed percent-encoding" };
538
+ }
539
+ }
540
+
541
+ function stripInlineCode(line) {
542
+ let result = line.replace(/(?<!\\)``[^`]*(?:``|$)/g, (match) => " ".repeat(match.length));
543
+ return result.replace(/(?<!\\)`[^`]*(?:`|$)/g, (match) => " ".repeat(match.length));
544
+ }
545
+
546
+ function assertLocaleList(locales) {
547
+ if (!Array.isArray(locales) || !locales.every((locale) => typeof locale === "string")) {
548
+ throw new TypeError("locales must be passed explicitly as an array of locale keys");
549
+ }
550
+ }
551
+
552
+ export function extractMdxAbsoluteLinks(content, locales) {
553
+ assertLocaleList(locales);
554
+ const localeAlternation = locales.length > 0
555
+ ? `(?:${locales.map((key) => `${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`).join("|")})?`
556
+ : "";
557
+ const issues = [];
558
+ const lines = content.split("\n");
559
+ let inCodeBlock = false;
560
+ for (let i = 0; i < lines.length; i += 1) {
561
+ const line = lines[i];
562
+ if (/^```/.test(line.trimStart())) {
563
+ inCodeBlock = !inCodeBlock;
564
+ continue;
565
+ }
566
+ if (inCodeBlock) continue;
567
+ const searchLine = stripInlineCode(line);
568
+ const mdRegex = new RegExp(`\\]\\((\\/${localeAlternation}docs\\/[^)]*)\\)`, "g");
569
+ let match;
570
+ while ((match = mdRegex.exec(searchLine)) !== null) issues.push({ href: match[1], line: i + 1 });
571
+ const jsxRegex = new RegExp(`href="(\\/${localeAlternation}docs\\/[^"]*)"`, "g");
572
+ while ((match = jsxRegex.exec(searchLine)) !== null) issues.push({ href: match[1], line: i + 1 });
573
+ }
574
+ return issues;
575
+ }
576
+
577
+ export function extractMdxFragmentLinks(content) {
578
+ const links = [];
579
+ const lines = content.split("\n");
580
+ let codeFenceOpener = null;
581
+ for (let i = 0; i < lines.length; i += 1) {
582
+ const line = lines[i];
583
+ const fence = /^([`~]{3,})/.exec(line.trimStart())?.[1];
584
+ if (fence !== undefined) {
585
+ if (codeFenceOpener === null) codeFenceOpener = fence;
586
+ else if (fence[0] === codeFenceOpener[0] && fence.length >= codeFenceOpener.length) codeFenceOpener = null;
587
+ continue;
588
+ }
589
+ if (codeFenceOpener !== null) continue;
590
+ const searchLine = stripInlineCode(line);
591
+ let match;
592
+ const markdownLink = /\]\(\s*([^\s)#]*#[^\s)]*)(?:\s+[^)]*)?\)/g;
593
+ while ((match = markdownLink.exec(searchLine)) !== null) {
594
+ if (!/^(?:https?:|\/\/|mailto:|javascript:|data:|tel:)/i.test(match[1])) links.push({ href: match[1], line: i + 1 });
595
+ }
596
+ const jsxHref = /\bhref\s*=\s*(?:"([^"]*#[^"]*)"|'([^']*#[^']*)')/g;
597
+ while ((match = jsxHref.exec(searchLine)) !== null) {
598
+ const href = match[1] ?? match[2];
599
+ if (!/^(?:https?:|\/\/|mailto:|javascript:|data:|tel:)/i.test(href)) links.push({ href, line: i + 1 });
600
+ }
601
+ }
602
+ return links;
603
+ }
604
+
605
+ /** Include h5/h6 targets and use the same text extraction as rendered TOC IDs. */
606
+ function allHierarchicalHeadingIds(body) {
607
+ return new Set(extractAllHeadingIds(body));
608
+ }
609
+
610
+ function extractStaticMdxIds(body) {
611
+ const ids = new Set();
612
+ let codeFenceOpener = null;
613
+ const visibleLines = [];
614
+ for (const line of body.split("\n")) {
615
+ const fence = /^([`~]{3,})/.exec(line.trimStart())?.[1];
616
+ if (fence !== undefined) {
617
+ if (codeFenceOpener === null) codeFenceOpener = fence;
618
+ else if (fence[0] === codeFenceOpener[0] && fence.length >= codeFenceOpener.length) codeFenceOpener = null;
619
+ visibleLines.push("");
620
+ continue;
621
+ }
622
+ visibleLines.push(codeFenceOpener === null ? stripInlineCode(line) : "");
623
+ }
624
+ const elements = visibleLines.join("\n");
625
+ // Same tokenising run as the built-HTML scans: a ">" inside a quoted MDX/JSX
626
+ // attribute value (title="a > b") must not truncate the tag and lose the id
627
+ // (#4048). The rest of this pattern keeps its own semantics — `\bid` also
628
+ // accepts `data-id`, and only non-empty quoted values count, unlike the
629
+ // built-HTML id scan.
630
+ //
631
+ // Two extra guards, scoped to this MDX scan only (the shared
632
+ // HTML_ATTRIBUTE_RUN above is untouched — the built-HTML scans still rely on
633
+ // multi-line quoted attributes):
634
+ //
635
+ // - MDX_UNESCAPED_LT_LOOKBEHIND: an escaped `\<` in prose is valid MDX and
636
+ // must not start a fake tag. Parity matters, not just "preceded by a
637
+ // backslash" — `\\<` is an escaped backslash followed by an active `<`.
638
+ // The lookbehind is anchored at the run's start (`(?<!\\)`) so it judges
639
+ // the whole contiguous backslash run, not a suffix of it.
640
+ // - MDX_ID_ATTRIBUTE_RUN: without this, a fake tag opened by an escaped `<`
641
+ // can have prose apostrophes read as a `'...'` quoted value that crosses
642
+ // a real `>` and swallows a real element's id on a later line (#4218).
643
+ // Disallowing a blank line inside a quoted value bounds the damage to a
644
+ // single paragraph while still letting a real JSX tag — including a
645
+ // quoted value spanning one newline — match.
646
+ const MDX_UNESCAPED_LT_LOOKBEHIND = /(?<!(?<!\\)(?:\\\\)*\\)/.source;
647
+ const MDX_ID_ATTRIBUTE_RUN =
648
+ /(?:"(?:(?!\r?\n[ \t]*\r?\n)[^"])*"|'(?:(?!\r?\n[ \t]*\r?\n)[^'])*'|[^>"'])/.source;
649
+ const regex = new RegExp(
650
+ `${MDX_UNESCAPED_LT_LOOKBEHIND}<[A-Za-z]${MDX_ID_ATTRIBUTE_RUN}*?\\bid\\s*=\\s*(?:"([^"]+)"|'([^']+)')${MDX_ID_ATTRIBUTE_RUN}*>`,
651
+ "gs",
652
+ );
653
+ let match;
654
+ while ((match = regex.exec(elements)) !== null) ids.add(match[1] ?? match[2]);
655
+ return ids;
656
+ }
657
+
658
+ async function resolveMdxTarget(sourceFile, href, contentDirs, locales, basePath) {
659
+ const { path: decodedPath } = parseHref(href);
660
+ let rawPath = decodedPath;
661
+ if (basePath !== "/" && rawPath.startsWith(basePath)) rawPath = "/" + rawPath.slice(basePath.length);
662
+ let target;
663
+ if (rawPath === "") target = sourceFile;
664
+ else if (rawPath.startsWith("/docs/")) target = resolve(contentDirs[0], rawPath.slice("/docs/".length));
665
+ else {
666
+ const locale = locales.find((key) => rawPath.startsWith(`/${key}/docs/`));
667
+ if (locale !== undefined) {
668
+ const localeDir = contentDirs[locales.indexOf(locale) + 1];
669
+ if (localeDir === undefined) return null;
670
+ target = resolve(localeDir, rawPath.slice(`/${locale}/docs/`.length));
671
+ } else if (rawPath.startsWith("/")) return null;
672
+ else target = resolve(dirname(sourceFile), rawPath);
673
+ }
674
+ const candidates = extname(target)
675
+ ? [target]
676
+ : [target, `${target}.mdx`, `${target}.md`, resolve(target, "index.mdx"), resolve(target, "index.md")];
677
+ for (const candidate of candidates) {
678
+ if (await fileExists(candidate) && (await stat(candidate)).isFile()) return candidate;
679
+ }
680
+ return null;
681
+ }
682
+
683
+ export async function checkMdxAnchors(contentDirs, rootDir, basePath = "/", locales, excludePatterns = []) {
684
+ assertLocaleList(locales);
685
+ const anchors = [];
686
+ const idCache = new Map();
687
+ for (const dir of contentDirs) {
688
+ if (!(await fileExists(dir))) continue;
689
+ for (const file of await collectFiles(dir, [".mdx", ".md"])) {
690
+ const content = await readFile(file, "utf-8");
691
+ for (const { href, line } of extractMdxFragmentLinks(content)) {
692
+ if (excludePatterns.some((pattern) => pattern.test(href))) continue;
693
+ const parsed = parseHref(href);
694
+ if (parsed.fragmentError !== null) {
695
+ anchors.push({ file: relative(rootDir, file), line, href, fragment: parsed.fragment, reason: parsed.fragmentError });
696
+ continue;
697
+ }
698
+ const target = await resolveMdxTarget(file, href, contentDirs, locales, basePath);
699
+ if (target === null) continue;
700
+ let ids = idCache.get(target);
701
+ if (ids === undefined) {
702
+ const targetBody = await readFile(target, "utf-8");
703
+ ids = allHierarchicalHeadingIds(targetBody);
704
+ for (const id of extractStaticMdxIds(targetBody)) ids.add(id);
705
+ idCache.set(target, ids);
706
+ }
707
+ if (!ids.has(parsed.fragment)) anchors.push({ file: relative(rootDir, file), line, href, fragment: parsed.fragment, reason: "missing target id" });
708
+ }
709
+ }
710
+ }
711
+ return anchors;
712
+ }
713
+
714
+ async function resolveBuiltPath(path, distDir, basePath, fileDir) {
715
+ let absolute = path;
716
+ if (!path.startsWith("/")) absolute = "/" + join(fileDir ? relative(distDir, fileDir) : "", path);
717
+ let stripped = absolute;
718
+ if (basePath !== "/" && stripped.startsWith(basePath)) stripped = "/" + stripped.slice(basePath.length);
719
+ const relPath = stripped.startsWith("/") ? stripped.slice(1) : stripped;
720
+ if (!relPath) return { type: "root", targetFile: join(distDir, "index.html") };
721
+ // A terminal slash is an explicit directory request, even when the
722
+ // directory name contains a dot (for example, /files/demo/x.js/). Check it
723
+ // before extname() so viewer pages can keep their trailing-slash route and
724
+ // still receive fragment validation against index.html.
725
+ if (relPath.endsWith("/")) {
726
+ const indexFile = join(distDir, relPath, "index.html");
727
+ return (await fileExists(indexFile)) ? { type: "directoryIndex", targetFile: indexFile } : { type: "missing", targetFile: null };
728
+ }
729
+ if (extname(relPath)) {
730
+ const targetFile = join(distDir, relPath);
731
+ return (await fileExists(targetFile)) ? { type: "file", targetFile } : { type: "missing", targetFile: null };
732
+ }
733
+ const indexFile = join(distDir, relPath, "index.html");
734
+ if (await fileExists(indexFile)) return { type: "directoryIndex", targetFile: indexFile };
735
+ const htmlFile = join(distDir, relPath + ".html");
736
+ if (await fileExists(htmlFile)) return { type: "file", targetFile: htmlFile };
737
+ return { type: "missing", targetFile: null };
738
+ }
739
+
740
+ async function resolveDistTarget(href, distDir, basePath = "/", fileDir = "", sourceFile = null) {
741
+ const { path: decodedPath, rawPath, fragment, fragmentError } = parseHref(href);
742
+ if (!rawPath) return { type: "root", targetFile: sourceFile ?? join(distDir, "index.html"), fragment, fragmentError };
743
+ const pathCandidates = rawPath === decodedPath ? [rawPath] : [rawPath, decodedPath];
744
+ for (const path of pathCandidates) {
745
+ const detail = await resolveBuiltPath(path, distDir, basePath, fileDir);
746
+ if (detail.type !== "missing") return { ...detail, fragment, fragmentError };
747
+ }
748
+ return { type: "missing", targetFile: null, fragment, fragmentError };
749
+ }
750
+
751
+ export async function resolveLinkDetail(href, distDir, basePath = "/", fileDir = "") {
752
+ return (await resolveDistTarget(href, distDir, basePath, fileDir)).type;
753
+ }
754
+
755
+ export async function resolveLink(href, distDir, basePath = "/", fileDir = "") {
756
+ return (await resolveDistTarget(href, distDir, basePath, fileDir)).type !== "missing";
757
+ }
758
+
759
+ /**
760
+ * Scan budget: exactly ONE anchor pass per page, and at most ONE id extraction
761
+ * per HTML target some fragment actually references. Ids are deliberately not
762
+ * extracted eagerly — most pages are never the target of a fragment link, and
763
+ * scanning them costs a full regex pass for a set nothing reads.
764
+ */
765
+ export async function checkHtmlLinksAndTrailing(
766
+ distDir,
767
+ rootDir,
768
+ basePath = "/",
769
+ excludePatterns = [],
770
+ checkTrailing = false,
771
+ ) {
772
+ const broken = [];
773
+ const anchors = [];
774
+ const trailingSlash = [];
775
+ const protocolRelative = [];
776
+ const idCache = new Map();
777
+ const cache = new Map();
778
+ const scanned = { links: 0, ids: 0 };
779
+ for (const file of await collectFiles(distDir, [".html"])) {
780
+ const content = await readFile(file, "utf-8");
781
+ const relFile = relative(rootDir, file);
782
+ // One anchor pass per page: internal links and the informational
783
+ // protocol-relative notices are disjoint buckets of the same match set.
784
+ const { links, protocolRelative: pageProtocolRelative } = classifyHtmlAnchorHrefs(content);
785
+ scanned.links += links.length;
786
+
787
+ // Filtered on the HREF, exactly like every other category below — an
788
+ // `excludePatterns` entry suppresses `//host/v/1.2/x` because the href
789
+ // matches, not because the page the href sits on is itself versioned.
790
+ // Without this the section had no off switch and consumers stopped
791
+ // reading it.
792
+ for (const { href, line } of pageProtocolRelative) {
793
+ if (excludePatterns.some((pattern) => pattern.test(href))) continue;
794
+ protocolRelative.push({ file: relFile, line, href });
795
+ }
796
+
797
+ for (const { href, line } of links) {
798
+ if (excludePatterns.some((pattern) => pattern.test(href))) continue;
799
+ const cacheKey = href.startsWith("/") ? href : `${file}:${href}`;
800
+ let detail = cache.get(cacheKey);
801
+ if (detail === undefined) {
802
+ detail = await resolveDistTarget(href, distDir, basePath, dirname(file), file);
803
+ cache.set(cacheKey, detail);
804
+ }
805
+ if (detail.type === "missing") broken.push({ file: relFile, line, href });
806
+ if (detail.fragment !== null) {
807
+ let reason = detail.fragmentError;
808
+ if (reason === null && detail.type !== "missing" && detail.targetFile !== null && extname(detail.targetFile) === ".html") {
809
+ let ids = idCache.get(detail.targetFile);
810
+ if (ids === undefined) {
811
+ // The page being walked is already in memory; any other target is
812
+ // re-read here rather than retained, so the walk never holds more
813
+ // than one page's HTML no matter how large dist/ is.
814
+ const targetHtml = detail.targetFile === file ? content : await readFile(detail.targetFile, "utf-8");
815
+ const targetIds = extractHtmlIds(targetHtml);
816
+ scanned.ids += targetIds.length;
817
+ ids = new Set(targetIds);
818
+ idCache.set(detail.targetFile, ids);
819
+ }
820
+ if (!ids.has(detail.fragment)) reason = "missing target id";
821
+ }
822
+ if (reason !== null) anchors.push({ file: relFile, line, href, fragment: detail.fragment, reason });
823
+ }
824
+ if (checkTrailing) {
825
+ const pathPart = href.split("#")[0].split("?")[0];
826
+ if (pathPart && pathPart !== "/" && pathPart !== "." && pathPart !== "./" && !pathPart.endsWith("/") && !extname(pathPart) && detail.type === "directoryIndex") {
827
+ trailingSlash.push({ file: relFile, line, href });
828
+ }
829
+ }
830
+ }
831
+ }
832
+ return { broken, anchors, trailingSlash, protocolRelative, scanned };
833
+ }
834
+
835
+ export async function checkMdxLinks(
836
+ contentDirs,
837
+ rootDir,
838
+ distDir = null,
839
+ basePath = "/",
840
+ locales = [],
841
+ ) {
842
+ assertLocaleList(locales);
843
+ const warnings = [];
844
+ for (const dir of contentDirs) {
845
+ if (!(await fileExists(dir))) continue;
846
+ for (const file of await collectFiles(dir, [".mdx", ".md"])) {
847
+ const content = await readFile(file, "utf-8");
848
+ for (const { href, line } of extractMdxAbsoluteLinks(content, locales)) {
849
+ if (distDir && await resolveLink(href, distDir, basePath)) continue;
850
+ warnings.push({ file: relative(rootDir, file), line, href });
851
+ }
852
+ }
853
+ }
854
+ return warnings;
855
+ }
856
+
857
+ // The authority segment is everything after "//" up to the first "/", "?",
858
+ // or "#". A dotless, colonless authority (no TLD-shaped or host:port-shaped
859
+ // piece) is flagged as a likely internal-path typo — see formatReport below.
860
+ function protocolRelativeAuthority(href) {
861
+ const rest = href.slice(2);
862
+ const end = rest.search(/[/?#]/);
863
+ return end === -1 ? rest : rest.slice(0, end);
864
+ }
865
+
866
+ function isLikelyInternalPathTypo(href) {
867
+ const authority = protocolRelativeAuthority(href);
868
+ return !authority.includes(".") && !authority.includes(":");
869
+ }
870
+
871
+ export function formatReport(brokenLinks, mdxWarnings, trailingSlashWarnings = [], anchorWarnings = [], protocolRelative = []) {
872
+ const lines = [];
873
+ const section = (title, entries, format) => {
874
+ if (entries.length === 0) return;
875
+ lines.push(title);
876
+ for (const entry of entries) lines.push(` ${format(entry)}`);
877
+ lines.push("");
878
+ };
879
+ section("=== Broken Links in Built HTML ===", brokenLinks, (e) => `${e.file}:${e.line} ${e.href}`);
880
+ section("=== Absolute Links Bypassing Base Path (MDX Source) ===", mdxWarnings, (e) => `${e.file}:${e.line} ${e.href}`);
881
+ section("=== Links Missing Trailing Slash ===", trailingSlashWarnings, (e) => `${e.file}:${e.line} ${e.href}`);
882
+ section("=== Invalid Anchors ===", anchorWarnings, (e) => `${e.file}:${e.line} ${e.href} (fragment: #${e.fragment}; ${e.reason})`);
883
+ // Informational only — excluded from `total` / the ✓/✗ line / the
884
+ // non-strict "Issues found" note below, deliberately (see #3934).
885
+ section("=== Protocol-Relative Links (informational) ===", protocolRelative, (e) =>
886
+ `${e.file}:${e.line} ${e.href}${isLikelyInternalPathTypo(e.href) ? ` ← authority has no dot or colon; may be an internal-path typo (e.g. ${e.href} → ${e.href.slice(1)})` : ""}`,
887
+ );
888
+ const total = brokenLinks.length + mdxWarnings.length + trailingSlashWarnings.length + anchorWarnings.length;
889
+ if (total === 0) lines.push("✓ No broken links, invalid anchors, or absolute path issues found");
890
+ else {
891
+ const parts = [];
892
+ if (brokenLinks.length) parts.push(`${brokenLinks.length} broken link${brokenLinks.length === 1 ? "" : "s"}`);
893
+ if (mdxWarnings.length) parts.push(`${mdxWarnings.length} absolute path warning${mdxWarnings.length === 1 ? "" : "s"}`);
894
+ if (trailingSlashWarnings.length) parts.push(`${trailingSlashWarnings.length} trailing slash warning${trailingSlashWarnings.length === 1 ? "" : "s"}`);
895
+ if (anchorWarnings.length) parts.push(`${anchorWarnings.length} invalid anchor${anchorWarnings.length === 1 ? "" : "s"}`);
896
+ lines.push(`✗ Found ${parts.join(" and ")}`);
897
+ }
898
+ return lines.join("\n");
899
+ }
900
+
901
+ export async function readAllowlist(allowlistPath) {
902
+ if (!allowlistPath || !(await fileExists(allowlistPath))) return new Set();
903
+ return new Set((await readFile(allowlistPath, "utf-8")).split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
904
+ }
905
+
906
+ function entryKey(entry) {
907
+ return `${entry.file}:${entry.line}:${entry.href}`;
908
+ }
909
+
910
+ async function main() {
911
+ const options = parseCliArgs(process.argv.slice(2));
912
+ if (options.help) {
913
+ console.log(CLI_USAGE);
914
+ return;
915
+ }
916
+ const rootDir = resolve(process.cwd());
917
+ const configPath = join(rootDir, "zfb.config.ts");
918
+ const config = await parseZfbConfig(configPath);
919
+ const contentDirs = [resolve(rootDir, config.docsDir), ...config.localeDirs.map((dir) => resolve(rootDir, dir))];
920
+ const distDir = join(rootDir, "dist");
921
+ const hasDist = await isDirectory(distDir);
922
+ const excludePatterns = [/\/v\/[^/]+\//];
923
+ console.log(`Checking links (base: ${config.basePath}, trailingSlash: ${config.trailingSlash})...`);
924
+ console.log(`Source scan: ${contentDirs.map((dir) => relative(rootDir, dir) || ".").join(", ")}${hasDist ? "; dist/ pass enabled" : "; dist/ absent (source-only)"}\n`);
925
+
926
+ const [{ broken, anchors: htmlAnchors, trailingSlash, protocolRelative, scanned }, mdxWarnings, mdxAnchors] = await Promise.all([
927
+ hasDist ? checkHtmlLinksAndTrailing(distDir, rootDir, config.basePath, excludePatterns, config.trailingSlash) : Promise.resolve({ broken: [], anchors: [], trailingSlash: [], protocolRelative: [], scanned: { links: 0, ids: 0 } }),
928
+ checkMdxLinks(contentDirs, rootDir, hasDist ? distDir : null, config.basePath, config.localeKeys),
929
+ checkMdxAnchors(contentDirs, rootDir, config.basePath, config.localeKeys, excludePatterns),
930
+ ]);
931
+ const anchorWarnings = [...htmlAnchors, ...mdxAnchors];
932
+ const allowlistPath = options.allowlistPath ? (options.allowlistPath.startsWith("/") ? options.allowlistPath : join(rootDir, options.allowlistPath)) : null;
933
+ const allowlist = await readAllowlist(allowlistPath);
934
+ const filter = (entries) => entries.filter((entry) => !allowlist.has(entryKey(entry)));
935
+ const realBroken = filter(broken);
936
+ const realAbsolute = filter(mdxWarnings);
937
+ const realAnchors = filter(anchorWarnings);
938
+ const realTrailing = filter(trailingSlash);
939
+ // The one category filtered BEFORE printing. It has no strict gate, so the
940
+ // allowlist is a consumer's only way to quiet a known-good entry, and
941
+ // quieting has to reach the section and the count line alike or the section
942
+ // keeps nagging and stops being read.
943
+ const shownProtocolRelative = filter(protocolRelative);
944
+ console.log(formatReport(broken, mdxWarnings, trailingSlash, anchorWarnings, shownProtocolRelative));
945
+ if (hasDist) console.log(`\nBuilt HTML scan: ${scanned.links} internal link${scanned.links === 1 ? "" : "s"} and ${scanned.ids} ID attribute${scanned.ids === 1 ? "" : "s"} inspected.`);
946
+ if (shownProtocolRelative.length > 0) console.log(`Protocol-relative links: ${shownProtocolRelative.length} found (informational only — see "Protocol-Relative Links" section above; not counted as issues).`);
947
+ // Protocol-relative suppressions are deliberately absent from this tally:
948
+ // the sentence is about strict-mode counts, and that category has none.
949
+ const skipped = broken.length - realBroken.length + mdxWarnings.length - realAbsolute.length + anchorWarnings.length - realAnchors.length + trailingSlash.length - realTrailing.length;
950
+ if (skipped > 0) console.log(`\nAllowlist: ${skipped} known exception${skipped === 1 ? "" : "s"} excluded from strict-mode counts (${allowlistPath}).`);
951
+ let failed = false;
952
+ if (options.strictBroken && realBroken.length) { console.log(`\n❌ STRICT FAIL: ${realBroken.length} broken link${realBroken.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
953
+ if (options.strictAbsolute && realAbsolute.length) { console.log(`\n❌ STRICT FAIL: ${realAbsolute.length} absolute MDX-source link${realAbsolute.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
954
+ if (options.strictAnchors && realAnchors.length) { console.log(`\n❌ STRICT FAIL: ${realAnchors.length} invalid anchor${realAnchors.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
955
+ if (options.strictTrailing && realTrailing.length) { console.log(`\n❌ STRICT FAIL: ${realTrailing.length} trailing-slash warning${realTrailing.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
956
+ if (failed) process.exitCode = 1;
957
+ else if ((broken.length || mdxWarnings.length || anchorWarnings.length || trailingSlash.length) && !options.strictBroken && !options.strictAbsolute && !options.strictAnchors && !options.strictTrailing) {
958
+ console.log("\nNote: Issues found but running in non-strict mode (exit 0).");
959
+ console.log("Use --strict-broken / --strict-absolute / --strict-anchors / --strict-trailing to fail on selected issue categories.");
960
+ }
961
+ }
962
+
963
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
964
+ if (isMain) {
965
+ main().catch((error) => {
966
+ console.error(error instanceof CliArgumentError ? error.message : error);
967
+ process.exitCode = 1;
968
+ });
969
+ }