create-zudo-doc 5.7.0 → 5.9.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.
package/README.md CHANGED
@@ -73,7 +73,8 @@ Each feature has a `--[no-]<flag>` form. Passing `--feature` enables it; `--no-f
73
73
  | `--[no-]sidebar-filter` | Real-time sidebar filter | on |
74
74
  | `--[no-]image-enlarge` | Click-to-enlarge for oversized images | on |
75
75
  | `--[no-]tag-governance` | Vocabulary-aware tag audit + suggest scripts | off |
76
- | `--[no-]claude-resources` | Auto-generate Claude Code docs (`CLAUDE.md`, `llms.txt`) | off |
76
+ | `--[no-]claude-resources` | Auto-generate Claude Code docs from `.claude/` | off |
77
+ | `--[no-]codex-resources` | Auto-generate Codex docs from `.codex/` + `AGENTS.md` | off |
77
78
  | `--[no-]claude-skills` | Ship zudo-doc Claude Code skills (design-system, translate, version-bump) | off |
78
79
  | `--[no-]design-token-panel` | Interactive panel for tweaking spacing, font, color tokens | off |
79
80
  | `--[no-]sidebar-resizer` | Draggable sidebar width handle | off |
@@ -127,6 +127,7 @@ export function generateCLAUDEFile(choices) {
127
127
  docHistory: "Document edit history",
128
128
  llmsTxt: "Generates llms.txt for LLM consumption",
129
129
  claudeResources: "Auto-generated docs for Claude Code resources",
130
+ codexResources: "Auto-generated docs for Codex resources (.codex/, AGENTS.md)",
130
131
  changelog: "Changelog page at `/docs/changelog`",
131
132
  tauri: "Desktop app wrapper (`cargo tauri dev` / `cargo tauri build`) — Cmd/Ctrl+F find bar via the package-owned `FindInPageInit` island (`findInPage: true` in `zfb.config.ts`)",
132
133
  tagGovernance: "Vocabulary-aware tag audit (`tags:audit`) / suggest (`tags:suggest`) scripts",
package/dist/cli.d.ts CHANGED
@@ -17,6 +17,7 @@ export interface CliArgs {
17
17
  sidebarToggle?: boolean;
18
18
  versioning?: boolean;
19
19
  claudeResources?: boolean;
20
+ codexResources?: boolean;
20
21
  claudeSkills?: boolean;
21
22
  claudeSkillsWriting?: boolean;
22
23
  docHistory?: boolean;
package/dist/constants.js CHANGED
@@ -212,6 +212,13 @@ export const FEATURES = [
212
212
  default: false,
213
213
  cliFlag: "claude-resources",
214
214
  },
215
+ {
216
+ value: "codexResources",
217
+ label: "Codex Resources",
218
+ hint: "Auto-generate Codex docs (.codex/, AGENTS.md)",
219
+ default: false,
220
+ cliFlag: "codex-resources",
221
+ },
215
222
  {
216
223
  value: "claudeSkills",
217
224
  label: "Claude skills (user-facing)",
@@ -0,0 +1,11 @@
1
+ import type { FeatureModule } from "../compose.js";
2
+ /**
3
+ * Codex-resources feature.
4
+ *
5
+ * Fully plugin-owned (`@takazudo/zudo-doc/plugins/codex-resources`,
6
+ * `zudoDocPreset()` wires it whenever `settings.codexResources` is
7
+ * truthy). Generation is package-owned. This feature's touch points are now
8
+ * just: `codexResources` + `defaultLocaleOnlyPrefixes` fields
9
+ * (`zfb-config-gen.ts`).
10
+ */
11
+ export declare const codexResourcesFeature: FeatureModule;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Codex-resources feature.
3
+ *
4
+ * Fully plugin-owned (`@takazudo/zudo-doc/plugins/codex-resources`,
5
+ * `zudoDocPreset()` wires it whenever `settings.codexResources` is
6
+ * truthy). Generation is package-owned. This feature's touch points are now
7
+ * just: `codexResources` + `defaultLocaleOnlyPrefixes` fields
8
+ * (`zfb-config-gen.ts`).
9
+ */
10
+ export const codexResourcesFeature = () => ({
11
+ name: "codexResources",
12
+ injections: [],
13
+ });
@@ -14,6 +14,7 @@ import { tocToggleFeature } from "./toc-toggle.js";
14
14
  import { docHistoryFeature } from "./doc-history.js";
15
15
  import { llmsTxtFeature } from "./llms-txt.js";
16
16
  import { claudeResourcesFeature } from "./claude-resources.js";
17
+ import { codexResourcesFeature } from "./codex-resources.js";
17
18
  import { designTokenPanelFeature } from "./design-token-panel.js";
18
19
  import { themePackSwitcherFeature } from "./theme-pack-switcher.js";
19
20
  import { i18nFeature } from "./i18n.js";
@@ -36,6 +37,7 @@ export const featureModules = {
36
37
  search: searchFeature,
37
38
  // sidebarFilter — built into sidebar-tree.tsx, stays in base
38
39
  claudeResources: claudeResourcesFeature,
40
+ codexResources: codexResourcesFeature,
39
41
  designTokenPanel: designTokenPanelFeature,
40
42
  themePackSwitcher: themePackSwitcherFeature,
41
43
  sidebarResizer: sidebarResizerFeature,
@@ -32,5 +32,5 @@ export declare function deriveDocSkillName(projectName: string): string;
32
32
  *
33
33
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
34
34
  */
35
- export declare const ZUDO_DOC_PIN = "^5.7.0";
35
+ export declare const ZUDO_DOC_PIN = "^5.9.0";
36
36
  export declare function scaffold(choices: UserChoices): Promise<void>;
package/dist/scaffold.js CHANGED
@@ -43,7 +43,7 @@ export function deriveDocSkillName(projectName) {
43
43
  *
44
44
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
45
45
  */
46
- export const ZUDO_DOC_PIN = "^5.7.0";
46
+ export const ZUDO_DOC_PIN = "^5.9.0";
47
47
  /**
48
48
  * Files in `templates/base/**` that must not be copied by the unconditional
49
49
  * base mirror. Each entry is matched against the path relative to
@@ -699,7 +699,7 @@ function generatePackageJson(choices) {
699
699
  // same reason. This is the ACCEPTED, permanent contract per #2668 — see
700
700
  // the "@takazudo/zdtp dep implication" note in
701
701
  // packages/zudo-doc/docs/adr/route-injection-seam.md.
702
- "@takazudo/zdtp": "0.4.11",
702
+ "@takazudo/zdtp": "0.4.12",
703
703
  // (@takazudo/zudo-doc-history-server is NOT here — it is gated on the
704
704
  // docHistory feature, see the block below. It was briefly unconditional
705
705
  // (#3080) to work around doc-history-area importing its `/exclude` subpath
@@ -748,7 +748,7 @@ function generatePackageJson(choices) {
748
748
  // `/exclude` at module scope from the always-bundled chrome graph; #3110
749
749
  // moved compileExclude into @takazudo/zudo-doc, so docHistory-OFF projects
750
750
  // no longer need the package at all.
751
- deps["@takazudo/zudo-doc-history-server"] = "^5.7.0";
751
+ deps["@takazudo/zudo-doc-history-server"] = "^5.9.0";
752
752
  // tsx is no longer needed here: the relocated package plugin imports the
753
753
  // runner directly (no `tsx -e` spawn) since the package ships compiled
754
754
  // dist/ — package-first migration #2321 (#2337).
@@ -775,6 +775,7 @@ function generatePackageJson(choices) {
775
775
  build: "zfb build",
776
776
  preview: "zfb preview",
777
777
  check: "zfb check",
778
+ "check:links": "node scripts/check-links.js",
778
779
  };
779
780
  if (choices.features.includes("docHistory")) {
780
781
  // A docHistory-enabled project needs the zfb dev server AND the
@@ -65,6 +65,7 @@ export const DEFAULT_MIRROR = {
65
65
  bodyFootUtilArea: false,
66
66
  versions: false,
67
67
  claudeResources: false,
68
+ codexResources: false,
68
69
  defaultLocaleOnlyPrefixes: [],
69
70
  footer: false,
70
71
  headerNav: [],
@@ -239,19 +240,22 @@ function buildDesiredConfig(choices) {
239
240
  desired.bodyFootUtilArea = false;
240
241
  }
241
242
  desired.versions = choices.features.includes("versioning") ? [] : false;
243
+ const defaultLocaleOnlyPrefixes = [];
242
244
  if (choices.features.includes("claudeResources")) {
243
245
  desired.claudeResources = { claudeDir: ".claude" };
244
- desired.defaultLocaleOnlyPrefixes = [
245
- "/docs/claude-md/",
246
- "/docs/claude-skills/",
247
- "/docs/claude-agents/",
248
- "/docs/claude-commands/",
249
- ];
246
+ defaultLocaleOnlyPrefixes.push("/docs/claude-md/", "/docs/claude-skills/", "/docs/claude-agents/", "/docs/claude-commands/");
250
247
  }
251
248
  else {
252
249
  desired.claudeResources = false;
253
- desired.defaultLocaleOnlyPrefixes = [];
254
250
  }
251
+ if (choices.features.includes("codexResources")) {
252
+ desired.codexResources = { codexDir: ".codex" };
253
+ defaultLocaleOnlyPrefixes.push("/docs/codex-agents-md/", "/docs/codex-config/", "/docs/codex-agents/", "/docs/codex-hooks/", "/docs/codex-rules/", "/docs/codex-skills/");
254
+ }
255
+ else {
256
+ desired.codexResources = false;
257
+ }
258
+ desired.defaultLocaleOnlyPrefixes = defaultLocaleOnlyPrefixes;
255
259
  // ── Footer ────────────────────────────────────────────────────────────
256
260
  if (choices.features.includes("footerNavGroup") ||
257
261
  choices.features.includes("footerCopyright") ||
@@ -297,6 +301,13 @@ function buildDesiredConfig(choices) {
297
301
  categoryMatch: "claude",
298
302
  });
299
303
  }
304
+ if (choices.features.includes("codexResources")) {
305
+ headerNav.push({
306
+ label: "Codex",
307
+ path: "/docs/codex",
308
+ categoryMatch: "codex",
309
+ });
310
+ }
300
311
  if (choices.features.includes("changelog")) {
301
312
  headerNav.push({
302
313
  label: "Changelog",
@@ -368,6 +379,7 @@ const FIELD_ORDER = [
368
379
  "bodyFootUtilArea",
369
380
  "versions",
370
381
  "claudeResources",
382
+ "codexResources",
371
383
  "defaultLocaleOnlyPrefixes",
372
384
  "footer",
373
385
  "headerNav",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "5.7.0",
3
+ "version": "5.9.0",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -0,0 +1,833 @@
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 { extractHeadings, slugify } 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
+ export function extractHtmlLinks(html) {
429
+ const links = [];
430
+ const regex = /<a\s[^>]*?href=(?:"([^"]*)"|'([^']*)')[^>]*>/gi;
431
+ let match;
432
+ let lastIndex = 0;
433
+ let line = 1;
434
+ while ((match = regex.exec(html)) !== null) {
435
+ const href = match[1] ?? match[2];
436
+ if (/^(?:https?:|mailto:|javascript:|data:|tel:)/i.test(href)) continue;
437
+ for (let i = lastIndex; i < match.index; i += 1) if (html[i] === "\n") line += 1;
438
+ lastIndex = match.index;
439
+ links.push({ href, line });
440
+ }
441
+ return links;
442
+ }
443
+
444
+ function safeDecodePath(path) {
445
+ try {
446
+ return decodeURIComponent(path);
447
+ } catch {
448
+ return path;
449
+ }
450
+ }
451
+
452
+ function parseHref(href) {
453
+ const hashAt = href.indexOf("#");
454
+ const beforeFragment = hashAt === -1 ? href : href.slice(0, hashAt);
455
+ const queryAt = beforeFragment.indexOf("?");
456
+ const rawPath = queryAt === -1 ? beforeFragment : beforeFragment.slice(0, queryAt);
457
+ const rawFragment = hashAt === -1 ? null : href.slice(hashAt + 1);
458
+ if (rawFragment === null) return { path: safeDecodePath(rawPath), fragment: null, fragmentError: null };
459
+ if (rawFragment === "") return { path: safeDecodePath(rawPath), fragment: "", fragmentError: "empty fragment" };
460
+ try {
461
+ return { path: safeDecodePath(rawPath), fragment: decodeURIComponent(rawFragment), fragmentError: null };
462
+ } catch {
463
+ return { path: safeDecodePath(rawPath), fragment: rawFragment, fragmentError: "malformed percent-encoding" };
464
+ }
465
+ }
466
+
467
+ function stripInlineCode(line) {
468
+ let result = line.replace(/(?<!\\)``[^`]*(?:``|$)/g, (match) => " ".repeat(match.length));
469
+ return result.replace(/(?<!\\)`[^`]*(?:`|$)/g, (match) => " ".repeat(match.length));
470
+ }
471
+
472
+ function assertLocaleList(locales) {
473
+ if (!Array.isArray(locales) || !locales.every((locale) => typeof locale === "string")) {
474
+ throw new TypeError("locales must be passed explicitly as an array of locale keys");
475
+ }
476
+ }
477
+
478
+ export function extractMdxAbsoluteLinks(content, locales) {
479
+ assertLocaleList(locales);
480
+ const localeAlternation = locales.length > 0
481
+ ? `(?:${locales.map((key) => `${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`).join("|")})?`
482
+ : "";
483
+ const issues = [];
484
+ const lines = content.split("\n");
485
+ let inCodeBlock = false;
486
+ for (let i = 0; i < lines.length; i += 1) {
487
+ const line = lines[i];
488
+ if (/^```/.test(line.trimStart())) {
489
+ inCodeBlock = !inCodeBlock;
490
+ continue;
491
+ }
492
+ if (inCodeBlock) continue;
493
+ const searchLine = stripInlineCode(line);
494
+ const mdRegex = new RegExp(`\\]\\((\\/${localeAlternation}docs\\/[^)]*)\\)`, "g");
495
+ let match;
496
+ while ((match = mdRegex.exec(searchLine)) !== null) issues.push({ href: match[1], line: i + 1 });
497
+ const jsxRegex = new RegExp(`href="(\\/${localeAlternation}docs\\/[^"]*)"`, "g");
498
+ while ((match = jsxRegex.exec(searchLine)) !== null) issues.push({ href: match[1], line: i + 1 });
499
+ }
500
+ return issues;
501
+ }
502
+
503
+ export function extractMdxFragmentLinks(content) {
504
+ const links = [];
505
+ const lines = content.split("\n");
506
+ let codeFenceOpener = null;
507
+ for (let i = 0; i < lines.length; i += 1) {
508
+ const line = lines[i];
509
+ const fence = /^([`~]{3,})/.exec(line.trimStart())?.[1];
510
+ if (fence !== undefined) {
511
+ if (codeFenceOpener === null) codeFenceOpener = fence;
512
+ else if (fence[0] === codeFenceOpener[0] && fence.length >= codeFenceOpener.length) codeFenceOpener = null;
513
+ continue;
514
+ }
515
+ if (codeFenceOpener !== null) continue;
516
+ const searchLine = stripInlineCode(line);
517
+ let match;
518
+ const markdownLink = /\]\(\s*([^\s)#]*#[^\s)]*)(?:\s+[^)]*)?\)/g;
519
+ while ((match = markdownLink.exec(searchLine)) !== null) {
520
+ if (!/^(?:https?:|mailto:|javascript:|data:|tel:)/i.test(match[1])) links.push({ href: match[1], line: i + 1 });
521
+ }
522
+ const jsxHref = /\bhref\s*=\s*(?:"([^"]*#[^"]*)"|'([^']*#[^']*)')/g;
523
+ while ((match = jsxHref.exec(searchLine)) !== null) {
524
+ const href = match[1] ?? match[2];
525
+ if (!/^(?:https?:|mailto:|javascript:|data:|tel:)/i.test(href)) links.push({ href, line: i + 1 });
526
+ }
527
+ }
528
+ return links;
529
+ }
530
+
531
+ function headingText(raw) {
532
+ return raw
533
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
534
+ .replace(/`([^`]+)`/g, "$1")
535
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
536
+ .replace(/(?<![\w])__([^_]+)__(?![\w])/g, "$1")
537
+ .replace(/\*([^*]+)\*/g, "$1")
538
+ .replace(/(?<![\w])_([^_]+)_(?![\w])/g, "$1")
539
+ .trim();
540
+ }
541
+
542
+ function allHierarchicalHeadingIds(body) {
543
+ const ids = new Set(extractHeadings(body).map((heading) => heading.slug));
544
+ const seen = new Map();
545
+ const stack = [];
546
+ let codeFenceOpener = null;
547
+ for (const line of body.split("\n")) {
548
+ const fence = /^([`~]{3,})/.exec(line.trimStart())?.[1];
549
+ if (fence !== undefined) {
550
+ if (codeFenceOpener === null) codeFenceOpener = fence;
551
+ else if (fence[0] === codeFenceOpener[0] && fence.length >= codeFenceOpener.length) codeFenceOpener = null;
552
+ continue;
553
+ }
554
+ if (codeFenceOpener !== null) continue;
555
+ const match = /^(#{2,6})[ \t]+(.+)$/.exec(line.trim());
556
+ if (match === null) continue;
557
+ const depth = match[1].length;
558
+ const base = slugify(headingText(match[2]));
559
+ if (base === "") continue;
560
+ while ((stack.at(-1)?.depth ?? -1) >= depth) stack.pop();
561
+ const parent = stack.at(-1);
562
+ const candidate = parent === undefined ? base : `${parent.id}-${base}`;
563
+ const count = seen.get(candidate) ?? 0;
564
+ seen.set(candidate, count + 1);
565
+ const id = count === 0 ? candidate : `${candidate}-${count}`;
566
+ stack.push({ depth, id });
567
+ ids.add(id);
568
+ }
569
+ return ids;
570
+ }
571
+
572
+ function extractStaticMdxIds(body) {
573
+ const ids = new Set();
574
+ let codeFenceOpener = null;
575
+ const visibleLines = [];
576
+ for (const line of body.split("\n")) {
577
+ const fence = /^([`~]{3,})/.exec(line.trimStart())?.[1];
578
+ if (fence !== undefined) {
579
+ if (codeFenceOpener === null) codeFenceOpener = fence;
580
+ else if (fence[0] === codeFenceOpener[0] && fence.length >= codeFenceOpener.length) codeFenceOpener = null;
581
+ visibleLines.push("");
582
+ continue;
583
+ }
584
+ visibleLines.push(codeFenceOpener === null ? stripInlineCode(line) : "");
585
+ }
586
+ const elements = visibleLines.join("\n");
587
+ const regex = /<[A-Za-z][^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>/gs;
588
+ let match;
589
+ while ((match = regex.exec(elements)) !== null) ids.add(match[1] ?? match[2]);
590
+ return ids;
591
+ }
592
+
593
+ async function resolveMdxTarget(sourceFile, href, contentDirs, locales, basePath) {
594
+ const { path: decodedPath } = parseHref(href);
595
+ let rawPath = decodedPath;
596
+ if (basePath !== "/" && rawPath.startsWith(basePath)) rawPath = "/" + rawPath.slice(basePath.length);
597
+ let target;
598
+ if (rawPath === "") target = sourceFile;
599
+ else if (rawPath.startsWith("/docs/")) target = resolve(contentDirs[0], rawPath.slice("/docs/".length));
600
+ else {
601
+ const locale = locales.find((key) => rawPath.startsWith(`/${key}/docs/`));
602
+ if (locale !== undefined) {
603
+ const localeDir = contentDirs[locales.indexOf(locale) + 1];
604
+ if (localeDir === undefined) return null;
605
+ target = resolve(localeDir, rawPath.slice(`/${locale}/docs/`.length));
606
+ } else if (rawPath.startsWith("/")) return null;
607
+ else target = resolve(dirname(sourceFile), rawPath);
608
+ }
609
+ const candidates = extname(target)
610
+ ? [target]
611
+ : [target, `${target}.mdx`, `${target}.md`, resolve(target, "index.mdx"), resolve(target, "index.md")];
612
+ for (const candidate of candidates) {
613
+ if (await fileExists(candidate) && (await stat(candidate)).isFile()) return candidate;
614
+ }
615
+ return null;
616
+ }
617
+
618
+ export async function checkMdxAnchors(contentDirs, rootDir, basePath = "/", locales, excludePatterns = []) {
619
+ assertLocaleList(locales);
620
+ const anchors = [];
621
+ const idCache = new Map();
622
+ for (const dir of contentDirs) {
623
+ if (!(await fileExists(dir))) continue;
624
+ for (const file of await collectFiles(dir, [".mdx", ".md"])) {
625
+ const content = await readFile(file, "utf-8");
626
+ for (const { href, line } of extractMdxFragmentLinks(content)) {
627
+ if (excludePatterns.some((pattern) => pattern.test(href))) continue;
628
+ const parsed = parseHref(href);
629
+ if (parsed.fragmentError !== null) {
630
+ anchors.push({ file: relative(rootDir, file), line, href, fragment: parsed.fragment, reason: parsed.fragmentError });
631
+ continue;
632
+ }
633
+ const target = await resolveMdxTarget(file, href, contentDirs, locales, basePath);
634
+ if (target === null) continue;
635
+ let ids = idCache.get(target);
636
+ if (ids === undefined) {
637
+ const targetBody = await readFile(target, "utf-8");
638
+ ids = allHierarchicalHeadingIds(targetBody);
639
+ for (const id of extractStaticMdxIds(targetBody)) ids.add(id);
640
+ idCache.set(target, ids);
641
+ }
642
+ if (!ids.has(parsed.fragment)) anchors.push({ file: relative(rootDir, file), line, href, fragment: parsed.fragment, reason: "missing target id" });
643
+ }
644
+ }
645
+ }
646
+ return anchors;
647
+ }
648
+
649
+ async function resolveDistTarget(href, distDir, basePath = "/", fileDir = "", sourceFile = null) {
650
+ const { path: clean, fragment, fragmentError } = parseHref(href);
651
+ if (!clean) return { type: "root", targetFile: sourceFile ?? join(distDir, "index.html"), fragment, fragmentError };
652
+ let absolute = clean;
653
+ if (!clean.startsWith("/")) absolute = "/" + join(fileDir ? relative(distDir, fileDir) : "", clean);
654
+ let stripped = absolute;
655
+ if (basePath !== "/" && stripped.startsWith(basePath)) stripped = "/" + stripped.slice(basePath.length);
656
+ const relPath = stripped.startsWith("/") ? stripped.slice(1) : stripped;
657
+ if (!relPath) return { type: "root", targetFile: join(distDir, "index.html"), fragment, fragmentError };
658
+ if (extname(relPath)) {
659
+ const targetFile = join(distDir, relPath);
660
+ return { type: (await fileExists(targetFile)) ? "file" : "missing", targetFile, fragment, fragmentError };
661
+ }
662
+ const indexFile = join(distDir, relPath, "index.html");
663
+ if (await fileExists(indexFile)) return { type: "directoryIndex", targetFile: indexFile, fragment, fragmentError };
664
+ const htmlFile = join(distDir, relPath + ".html");
665
+ if (await fileExists(htmlFile)) return { type: "file", targetFile: htmlFile, fragment, fragmentError };
666
+ return { type: "missing", targetFile: null, fragment, fragmentError };
667
+ }
668
+
669
+ export async function resolveLinkDetail(href, distDir, basePath = "/", fileDir = "") {
670
+ return (await resolveDistTarget(href, distDir, basePath, fileDir)).type;
671
+ }
672
+
673
+ export async function resolveLink(href, distDir, basePath = "/", fileDir = "") {
674
+ return (await resolveDistTarget(href, distDir, basePath, fileDir)).type !== "missing";
675
+ }
676
+
677
+ export async function checkHtmlLinksAndTrailing(
678
+ distDir,
679
+ rootDir,
680
+ basePath = "/",
681
+ excludePatterns = [],
682
+ checkTrailing = false,
683
+ ) {
684
+ const broken = [];
685
+ const anchors = [];
686
+ const trailingSlash = [];
687
+ const idCache = new Map();
688
+ const cache = new Map();
689
+ for (const file of await collectFiles(distDir, [".html"])) {
690
+ const content = await readFile(file, "utf-8");
691
+ for (const { href, line } of extractHtmlLinks(content)) {
692
+ if (excludePatterns.some((pattern) => pattern.test(href))) continue;
693
+ const cacheKey = href.startsWith("/") ? href : `${file}:${href}`;
694
+ let detail = cache.get(cacheKey);
695
+ if (detail === undefined) {
696
+ detail = await resolveDistTarget(href, distDir, basePath, dirname(file), file);
697
+ cache.set(cacheKey, detail);
698
+ }
699
+ if (detail.type === "missing") broken.push({ file: relative(rootDir, file), line, href });
700
+ if (detail.fragment !== null) {
701
+ let reason = detail.fragmentError;
702
+ if (reason === null && detail.type !== "missing" && detail.targetFile !== null && extname(detail.targetFile) === ".html") {
703
+ let ids = idCache.get(detail.targetFile);
704
+ if (ids === undefined) {
705
+ const targetHtml = await readFile(detail.targetFile, "utf-8");
706
+ ids = new Set();
707
+ const idRegex = /\bid\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
708
+ let idMatch;
709
+ while ((idMatch = idRegex.exec(targetHtml)) !== null) ids.add(idMatch[1] ?? idMatch[2]);
710
+ idCache.set(detail.targetFile, ids);
711
+ }
712
+ if (!ids.has(detail.fragment)) reason = "missing target id";
713
+ }
714
+ if (reason !== null) anchors.push({ file: relative(rootDir, file), line, href, fragment: detail.fragment, reason });
715
+ }
716
+ if (checkTrailing) {
717
+ const pathPart = href.split("#")[0].split("?")[0];
718
+ if (pathPart && pathPart !== "/" && pathPart !== "." && pathPart !== "./" && !pathPart.endsWith("/") && !extname(pathPart) && detail.type === "directoryIndex") {
719
+ trailingSlash.push({ file: relative(rootDir, file), line, href });
720
+ }
721
+ }
722
+ }
723
+ }
724
+ return { broken, anchors, trailingSlash };
725
+ }
726
+
727
+ export async function checkMdxLinks(
728
+ contentDirs,
729
+ rootDir,
730
+ distDir = null,
731
+ basePath = "/",
732
+ locales = [],
733
+ ) {
734
+ assertLocaleList(locales);
735
+ const warnings = [];
736
+ for (const dir of contentDirs) {
737
+ if (!(await fileExists(dir))) continue;
738
+ for (const file of await collectFiles(dir, [".mdx", ".md"])) {
739
+ const content = await readFile(file, "utf-8");
740
+ for (const { href, line } of extractMdxAbsoluteLinks(content, locales)) {
741
+ if (distDir && await resolveLink(href, distDir, basePath)) continue;
742
+ warnings.push({ file: relative(rootDir, file), line, href });
743
+ }
744
+ }
745
+ }
746
+ return warnings;
747
+ }
748
+
749
+ export function formatReport(brokenLinks, mdxWarnings, trailingSlashWarnings = [], anchorWarnings = []) {
750
+ const lines = [];
751
+ const section = (title, entries, format) => {
752
+ if (entries.length === 0) return;
753
+ lines.push(title);
754
+ for (const entry of entries) lines.push(` ${format(entry)}`);
755
+ lines.push("");
756
+ };
757
+ section("=== Broken Links in Built HTML ===", brokenLinks, (e) => `${e.file}:${e.line} ${e.href}`);
758
+ section("=== Absolute Links Bypassing Base Path (MDX Source) ===", mdxWarnings, (e) => `${e.file}:${e.line} ${e.href}`);
759
+ section("=== Links Missing Trailing Slash ===", trailingSlashWarnings, (e) => `${e.file}:${e.line} ${e.href}`);
760
+ section("=== Invalid Anchors ===", anchorWarnings, (e) => `${e.file}:${e.line} ${e.href} (fragment: #${e.fragment}; ${e.reason})`);
761
+ const total = brokenLinks.length + mdxWarnings.length + trailingSlashWarnings.length + anchorWarnings.length;
762
+ if (total === 0) lines.push("✓ No broken links, invalid anchors, or absolute path issues found");
763
+ else {
764
+ const parts = [];
765
+ if (brokenLinks.length) parts.push(`${brokenLinks.length} broken link${brokenLinks.length === 1 ? "" : "s"}`);
766
+ if (mdxWarnings.length) parts.push(`${mdxWarnings.length} absolute path warning${mdxWarnings.length === 1 ? "" : "s"}`);
767
+ if (trailingSlashWarnings.length) parts.push(`${trailingSlashWarnings.length} trailing slash warning${trailingSlashWarnings.length === 1 ? "" : "s"}`);
768
+ if (anchorWarnings.length) parts.push(`${anchorWarnings.length} invalid anchor${anchorWarnings.length === 1 ? "" : "s"}`);
769
+ lines.push(`✗ Found ${parts.join(" and ")}`);
770
+ }
771
+ return lines.join("\n");
772
+ }
773
+
774
+ export async function readAllowlist(allowlistPath) {
775
+ if (!allowlistPath || !(await fileExists(allowlistPath))) return new Set();
776
+ return new Set((await readFile(allowlistPath, "utf-8")).split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
777
+ }
778
+
779
+ function entryKey(entry) {
780
+ return `${entry.file}:${entry.line}:${entry.href}`;
781
+ }
782
+
783
+ async function main() {
784
+ const options = parseCliArgs(process.argv.slice(2));
785
+ if (options.help) {
786
+ console.log(CLI_USAGE);
787
+ return;
788
+ }
789
+ const rootDir = resolve(process.cwd());
790
+ const configPath = join(rootDir, "zfb.config.ts");
791
+ const config = await parseZfbConfig(configPath);
792
+ const contentDirs = [resolve(rootDir, config.docsDir), ...config.localeDirs.map((dir) => resolve(rootDir, dir))];
793
+ const distDir = join(rootDir, "dist");
794
+ const hasDist = await isDirectory(distDir);
795
+ const excludePatterns = [/\/v\/[^/]+\//];
796
+ console.log(`Checking links (base: ${config.basePath}, trailingSlash: ${config.trailingSlash})...`);
797
+ console.log(`Source scan: ${contentDirs.map((dir) => relative(rootDir, dir) || ".").join(", ")}${hasDist ? "; dist/ pass enabled" : "; dist/ absent (source-only)"}\n`);
798
+
799
+ const [{ broken, anchors: htmlAnchors, trailingSlash }, mdxWarnings, mdxAnchors] = await Promise.all([
800
+ hasDist ? checkHtmlLinksAndTrailing(distDir, rootDir, config.basePath, excludePatterns, config.trailingSlash) : Promise.resolve({ broken: [], anchors: [], trailingSlash: [] }),
801
+ checkMdxLinks(contentDirs, rootDir, hasDist ? distDir : null, config.basePath, config.localeKeys),
802
+ checkMdxAnchors(contentDirs, rootDir, config.basePath, config.localeKeys, excludePatterns),
803
+ ]);
804
+ const anchorWarnings = [...htmlAnchors, ...mdxAnchors];
805
+ const allowlistPath = options.allowlistPath ? (options.allowlistPath.startsWith("/") ? options.allowlistPath : join(rootDir, options.allowlistPath)) : null;
806
+ const allowlist = await readAllowlist(allowlistPath);
807
+ const filter = (entries) => entries.filter((entry) => !allowlist.has(entryKey(entry)));
808
+ const realBroken = filter(broken);
809
+ const realAbsolute = filter(mdxWarnings);
810
+ const realAnchors = filter(anchorWarnings);
811
+ const realTrailing = filter(trailingSlash);
812
+ console.log(formatReport(broken, mdxWarnings, trailingSlash, anchorWarnings));
813
+ const skipped = broken.length - realBroken.length + mdxWarnings.length - realAbsolute.length + anchorWarnings.length - realAnchors.length + trailingSlash.length - realTrailing.length;
814
+ if (skipped > 0) console.log(`\nAllowlist: ${skipped} known exception${skipped === 1 ? "" : "s"} excluded from strict-mode counts (${allowlistPath}).`);
815
+ let failed = false;
816
+ if (options.strictBroken && realBroken.length) { console.log(`\n❌ STRICT FAIL: ${realBroken.length} broken link${realBroken.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
817
+ if (options.strictAbsolute && realAbsolute.length) { console.log(`\n❌ STRICT FAIL: ${realAbsolute.length} absolute MDX-source link${realAbsolute.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
818
+ if (options.strictAnchors && realAnchors.length) { console.log(`\n❌ STRICT FAIL: ${realAnchors.length} invalid anchor${realAnchors.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
819
+ if (options.strictTrailing && realTrailing.length) { console.log(`\n❌ STRICT FAIL: ${realTrailing.length} trailing-slash warning${realTrailing.length === 1 ? "" : "s"} (after allowlist).`); failed = true; }
820
+ if (failed) process.exitCode = 1;
821
+ else if ((broken.length || mdxWarnings.length || anchorWarnings.length || trailingSlash.length) && !options.strictBroken && !options.strictAbsolute && !options.strictAnchors && !options.strictTrailing) {
822
+ console.log("\nNote: Issues found but running in non-strict mode (exit 0).");
823
+ console.log("Use --strict-broken / --strict-absolute / --strict-anchors / --strict-trailing to fail on selected issue categories.");
824
+ }
825
+ }
826
+
827
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
828
+ if (isMain) {
829
+ main().catch((error) => {
830
+ console.error(error instanceof CliArgumentError ? error.message : error);
831
+ process.exitCode = 1;
832
+ });
833
+ }
@@ -210,7 +210,7 @@ Check `zfb.config.ts`: if the `zudoDoc({...})` call sets a non-empty `locales` f
210
210
  Two carve-outs — do NOT create secondary-locale mirrors for these:
211
211
 
212
212
  - Pages with `generated: true` in frontmatter (build-generated content).
213
- - Paths listed in the `defaultLocaleOnlyPrefixes` setting in `zfb.config.ts` — default-locale-only by design (the Claude Resources feature, when enabled, registers its four `/docs/claude-*` prefixes there).
213
+ - Paths listed in the `defaultLocaleOnlyPrefixes` setting in `zfb.config.ts` — default-locale-only by design (the Claude Resources and Codex Resources features, when enabled, register four `/docs/claude-*` prefixes and six `/docs/codex-*` prefixes there; their top-level `/docs/claude/` and `/docs/codex/` indexes remain bilingual).
214
214
 
215
215
  ## Common Mistakes (Do Not Do)
216
216