create-zudo-doc 1.1.0 → 1.3.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/dist/api.d.ts CHANGED
@@ -16,5 +16,11 @@ export interface CreateOptions {
16
16
  packageManager: "pnpm" | "npm" | "yarn" | "bun";
17
17
  /** Install dependencies after scaffolding (default: false) */
18
18
  install?: boolean;
19
+ /**
20
+ * Initialize a git repository + initial commit after scaffolding
21
+ * (default: false). The CLI defaults this on; the programmatic API defaults
22
+ * it off so automation / test callers never create unexpected repos.
23
+ */
24
+ git?: boolean;
19
25
  }
20
26
  export declare function createZudoDoc(options: CreateOptions): Promise<string>;
package/dist/api.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import path from "path";
2
2
  import { scaffold } from "./scaffold.js";
3
- import { installDependencies, validateProjectName } from "./utils.js";
3
+ import { initGitRepo, installDependencies, validateProjectName } from "./utils.js";
4
4
  export async function createZudoDoc(options) {
5
- const { install = false, ...rest } = options;
5
+ const { install = false, git = false, ...rest } = options;
6
6
  const nameError = validateProjectName(rest.projectName);
7
7
  if (nameError)
8
8
  throw new Error(`Invalid projectName: ${nameError}`);
@@ -12,5 +12,8 @@ export async function createZudoDoc(options) {
12
12
  if (install) {
13
13
  installDependencies(targetDir, choices.packageManager);
14
14
  }
15
+ if (git) {
16
+ initGitRepo(targetDir);
17
+ }
15
18
  return targetDir;
16
19
  }
package/dist/cli.d.ts CHANGED
@@ -33,6 +33,8 @@ export interface CliArgs {
33
33
  preset?: string;
34
34
  pm?: "pnpm" | "npm" | "yarn" | "bun";
35
35
  install?: boolean;
36
+ /** Initialize a git repository + initial commit after scaffolding (default on). */
37
+ git?: boolean;
36
38
  yes?: boolean;
37
39
  help?: boolean;
38
40
  }
package/dist/cli.js CHANGED
@@ -67,6 +67,8 @@ export function parseArgs(argv = process.argv.slice(2)) {
67
67
  }
68
68
  if (wasPassed("install"))
69
69
  args.install = raw["install"] !== false;
70
+ if (wasPassed("git"))
71
+ args.git = raw["git"] !== false;
70
72
  if (raw.yes || raw.y)
71
73
  args.yes = true;
72
74
  if (raw.help || raw.h)
@@ -95,6 +97,8 @@ ${featureHelp}
95
97
  --preset <path> Load settings from a JSON preset file (use "-" for stdin)
96
98
  --pm <manager> pnpm | npm | yarn | bun
97
99
  --[no-]install Install dependencies after scaffolding
100
+ --[no-]git Initialize a git repository + initial commit
101
+ (default: on; enables doc-history metadata)
98
102
  -y, --yes Use defaults for unspecified options, skip prompts
99
103
  -h, --help Show this help message
100
104
 
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { FEATURES } from "./constants.js";
6
6
  import { loadPreset } from "./preset.js";
7
7
  import { runPrompts } from "./prompts.js";
8
8
  import { scaffold } from "./scaffold.js";
9
- import { installDependencies } from "./utils.js";
9
+ import { installDependencies, initGitRepo } from "./utils.js";
10
10
  async function main() {
11
11
  const args = parseArgs();
12
12
  // Handle --help
@@ -144,6 +144,31 @@ async function main() {
144
144
  s2.stop("Installation failed. Run install manually.");
145
145
  }
146
146
  }
147
+ // Initialize a git repository (default on; --no-git to opt out). doc-history
148
+ // reads `git log` for each page's Created/Updated/Author block, so a project
149
+ // without git renders empty history. Runs after install so the lockfile is
150
+ // part of the initial commit; auto-skips if the target is already inside a
151
+ // repo or if git is unavailable.
152
+ const shouldInitGit = args.git ?? true;
153
+ if (shouldInitGit) {
154
+ const s3 = p.spinner();
155
+ s3.start("Initializing git repository...");
156
+ const result = initGitRepo(targetDir);
157
+ switch (result.status) {
158
+ case "ok":
159
+ s3.stop("Initialized git repository with an initial commit.");
160
+ break;
161
+ case "skipped-existing-repo":
162
+ s3.stop("Already inside a git repository — skipped git init.");
163
+ break;
164
+ case "skipped-no-git":
165
+ s3.stop("git not found — skipped git init.");
166
+ break;
167
+ case "failed":
168
+ s3.stop("Could not initialize git — continuing without it.");
169
+ break;
170
+ }
171
+ }
147
172
  p.outro(`${pc.green("Done!")} Your project is ready at ${pc.cyan(choices.projectName)}`);
148
173
  console.log();
149
174
  console.log(` ${pc.bold("Next steps:")}`);
@@ -11,5 +11,5 @@ export { getSecondaryLang };
11
11
  *
12
12
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
13
13
  */
14
- export declare const ZUDO_DOC_PIN = "^1.1.0";
14
+ export declare const ZUDO_DOC_PIN = "^1.3.0";
15
15
  export declare function scaffold(choices: UserChoices): Promise<void>;
package/dist/scaffold.js CHANGED
@@ -18,7 +18,7 @@ export { getSecondaryLang };
18
18
  *
19
19
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
20
20
  */
21
- export const ZUDO_DOC_PIN = "^1.1.0";
21
+ export const ZUDO_DOC_PIN = "^1.3.0";
22
22
  /**
23
23
  * Files in `templates/base/**` that must never be copied into a generated
24
24
  * project. Each entry is matched against the path relative to `templates/base/`
@@ -475,13 +475,19 @@ function generatePackageJson(choices) {
475
475
  // (e.g. `@/*`) into its synthetic tsconfig (zfb#1238) — fixes silent island
476
476
  // hydration failure under route injection. Unblocks packageOwnedRoutes.
477
477
  // No consumer-facing / CLI breaking change.
478
- // next.67 (current pin): routine toolchain bump from next.65, adopted in
478
+ // next.68: routine toolchain bump from next.67, adopted in
479
479
  // lockstep with the root package.json pins. No consumer-facing / CLI change.
480
- "@takazudo/zfb": "0.1.0-next.67",
481
- "@takazudo/zfb-runtime": "0.1.0-next.67",
480
+ // next.69: routine toolchain bump from next.68, adopted in
481
+ // lockstep with the root package.json pins. No consumer-facing / CLI change.
482
+ // next.70 (current pin): routine toolchain bump from next.69, re-aligned
483
+ // with the root package.json pins (root was bumped in b5489acf; this
484
+ // scaffold pin lagged at next.69 and broke check-pin-parity). No
485
+ // consumer-facing / CLI change.
486
+ "@takazudo/zfb": "0.1.0-next.70",
487
+ "@takazudo/zfb-runtime": "0.1.0-next.70",
482
488
  // zfb-adapter-cloudflare — required for any route with `prerender = false`.
483
489
  // Pinned in lockstep with @takazudo/zfb.
484
- "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.67",
490
+ "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.70",
485
491
  // @takazudo/zudo-doc — published from this monorepo via
486
492
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
487
493
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
@@ -564,7 +570,7 @@ function generatePackageJson(choices) {
564
570
  // @takazudo/zudo-doc/integrations/doc-history which in turn imports
565
571
  // @takazudo/zudo-doc-history-server/git-history. Without this dep the
566
572
  // plugin host fails at init with ERR_MODULE_NOT_FOUND — W8A (#1739).
567
- deps["@takazudo/zudo-doc-history-server"] = "^1.1.0";
573
+ deps["@takazudo/zudo-doc-history-server"] = "^1.3.0";
568
574
  // tsx is no longer needed here: the relocated package plugin imports the
569
575
  // runner directly (no `tsx -e` spawn) since the package ships compiled
570
576
  // dist/ — package-first migration #2321 (#2337).
package/dist/utils.d.ts CHANGED
@@ -7,6 +7,33 @@
7
7
  */
8
8
  export declare function validateProjectName(name: string): string | null;
9
9
  export declare function installDependencies(dir: string, pm: string): void;
10
+ export type GitInitResult = {
11
+ status: "ok";
12
+ } | {
13
+ status: "skipped-existing-repo";
14
+ } | {
15
+ status: "skipped-no-git";
16
+ } | {
17
+ status: "failed";
18
+ message: string;
19
+ };
20
+ /**
21
+ * Initialize a git repository in `dir` and create an initial commit.
22
+ *
23
+ * Why: zudo-doc's doc-history feature reads `git log` for each page's
24
+ * Created/Updated/Author block. A scaffolded project with no git repo renders
25
+ * empty history — and on older `@takazudo/zudo-doc-history-server` it crashed
26
+ * `pnpm dev` outright (the preBuild hook ran `git rev-parse` and threw).
27
+ * Initializing git here makes the feature work out of the box.
28
+ *
29
+ * Safe by construction:
30
+ * - `skipped-no-git` when git is not installed;
31
+ * - `skipped-existing-repo` when `dir` is already inside a git work tree (e.g.
32
+ * scaffolding into an existing monorepo) — never nest repositories;
33
+ * - the initial commit only falls back to a neutral identity when the user has
34
+ * none configured, so a normal user's commit keeps their own identity.
35
+ */
36
+ export declare function initGitRepo(dir: string): GitInitResult;
10
37
  export declare function capitalize(str: string): string;
11
38
  /** Get a short uppercase label for a language code (e.g. "en" → "EN", "zh-cn" → "ZH-CN"). */
12
39
  export declare function getLangLabel(langCode: string): string;
package/dist/utils.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, execFileSync } from "child_process";
2
2
  import fs from "fs-extra";
3
3
  // Project-name grammar (locked by F4 — S4 #2013):
4
4
  // /^[a-z0-9][a-z0-9._-]*$/, max 214 chars, unscoped, used as both directory
@@ -36,6 +36,77 @@ export function installDependencies(dir, pm) {
36
36
  // Use pipe to avoid garbled output when used alongside spinner
37
37
  execSync(cmd, { cwd: dir, stdio: "pipe" });
38
38
  }
39
+ /**
40
+ * Initialize a git repository in `dir` and create an initial commit.
41
+ *
42
+ * Why: zudo-doc's doc-history feature reads `git log` for each page's
43
+ * Created/Updated/Author block. A scaffolded project with no git repo renders
44
+ * empty history — and on older `@takazudo/zudo-doc-history-server` it crashed
45
+ * `pnpm dev` outright (the preBuild hook ran `git rev-parse` and threw).
46
+ * Initializing git here makes the feature work out of the box.
47
+ *
48
+ * Safe by construction:
49
+ * - `skipped-no-git` when git is not installed;
50
+ * - `skipped-existing-repo` when `dir` is already inside a git work tree (e.g.
51
+ * scaffolding into an existing monorepo) — never nest repositories;
52
+ * - the initial commit only falls back to a neutral identity when the user has
53
+ * none configured, so a normal user's commit keeps their own identity.
54
+ */
55
+ export function initGitRepo(dir) {
56
+ // Is git available at all?
57
+ try {
58
+ execFileSync("git", ["--version"], { stdio: "ignore" });
59
+ }
60
+ catch {
61
+ return { status: "skipped-no-git" };
62
+ }
63
+ // Already inside a git work tree? Don't create a nested repository.
64
+ try {
65
+ const inside = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
66
+ cwd: dir,
67
+ encoding: "utf-8",
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ }).trim();
70
+ if (inside === "true")
71
+ return { status: "skipped-existing-repo" };
72
+ }
73
+ catch {
74
+ // git exits non-zero when `dir` is not inside a repo — the expected case.
75
+ }
76
+ const COMMIT_MESSAGE = "Initial commit from create-zudo-doc";
77
+ try {
78
+ execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
79
+ execFileSync("git", ["add", "-A"], { cwd: dir, stdio: "ignore" });
80
+ try {
81
+ execFileSync("git", ["commit", "-q", "-m", COMMIT_MESSAGE], {
82
+ cwd: dir,
83
+ stdio: "ignore",
84
+ });
85
+ }
86
+ catch {
87
+ // No git identity configured on this machine — retry with a neutral
88
+ // fallback so the initial commit still succeeds. Users with a configured
89
+ // identity never reach this branch (their identity is used above).
90
+ execFileSync("git", [
91
+ "-c",
92
+ "user.name=create-zudo-doc",
93
+ "-c",
94
+ "user.email=create-zudo-doc@users.noreply.github.com",
95
+ "commit",
96
+ "-q",
97
+ "-m",
98
+ COMMIT_MESSAGE,
99
+ ], { cwd: dir, stdio: "ignore" });
100
+ }
101
+ return { status: "ok" };
102
+ }
103
+ catch (err) {
104
+ return {
105
+ status: "failed",
106
+ message: err instanceof Error ? err.message : String(err),
107
+ };
108
+ }
109
+ }
39
110
  export function capitalize(str) {
40
111
  return str.replace(/\b\w/g, (c) => c.toUpperCase());
41
112
  }
@@ -28,6 +28,8 @@ export function generateZfbConfig(_choices) {
28
28
  lines.push(`import { zudoDocPreset } from "@takazudo/zudo-doc/preset";`);
29
29
  lines.push(`import { settings } from "./src/config/settings";`);
30
30
  lines.push(`import { buildDocsSchema } from "./src/config/docs-schema";`);
31
+ lines.push(`import { translations } from "./src/config/i18n";`);
32
+ lines.push(`import { colorSchemes } from "./src/config/color-schemes";`);
31
33
  lines.push(``);
32
34
  // --- Directive vocabulary ---
33
35
  // The seven canonical directives registered in pages/_mdx-components.ts.
@@ -54,7 +56,7 @@ export function generateZfbConfig(_choices) {
54
56
  lines.push(` base: settings.base,`);
55
57
  lines.push(``);
56
58
  lines.push(` // ── Preset-owned fields (content collections, plugins, markdown, …) ────────`);
57
- lines.push(` ...zudoDocPreset({ settings, buildDocsSchema, directiveVocabulary }),`);
59
+ lines.push(` ...zudoDocPreset({ settings, buildDocsSchema, directiveVocabulary, translations, colorSchemes }),`);
58
60
  lines.push(`});`);
59
61
  return lines.join("\n") + "\n";
60
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -55,7 +55,7 @@ export function detectLocaleFromPath(path: string): Locale {
55
55
  }
56
56
 
57
57
  /** UI string translations */
58
- const translations: Record<string, Record<string, string>> = {
58
+ export const translations: Record<string, Record<string, string>> = {
59
59
  en: {
60
60
  "nav.gettingStarted": "Getting Started",
61
61
  "nav.learn": "Learn",
@@ -111,15 +111,20 @@ describe("generateClaudeResourcesDocs", () => {
111
111
  }
112
112
  });
113
113
 
114
- it("generates skill as flat .mdx file", () => {
114
+ it("generates skill page as <dir>/index.mdx", () => {
115
115
  generateClaudeResourcesDocs({
116
116
  claudeDir,
117
117
  projectRoot: tmpDir,
118
118
  docsDir,
119
119
  });
120
120
 
121
+ // The skill page is the directory index so `./ref-<name>` links resolve
122
+ // against the source file path (#2411) — NOT a flat `<dir>.mdx`.
123
+ const indexPath = path.join(docsDir, "claude-skills", "test-skill", "index.mdx");
124
+ expect(fs.existsSync(indexPath)).toBe(true);
125
+
121
126
  const flatPath = path.join(docsDir, "claude-skills", "test-skill.mdx");
122
- expect(fs.existsSync(flatPath)).toBe(true);
127
+ expect(fs.existsSync(flatPath)).toBe(false);
123
128
  });
124
129
  });
125
130
 
@@ -150,7 +155,7 @@ describe("generateClaudeResourcesDocs", () => {
150
155
  });
151
156
 
152
157
  const skillPage = fs.readFileSync(
153
- path.join(docsDir, "claude-skills", "test-skill.mdx"),
158
+ path.join(docsDir, "claude-skills", "test-skill", "index.mdx"),
154
159
  "utf8",
155
160
  );
156
161
  const parsed = matter(skillPage);
@@ -168,7 +173,7 @@ describe("generateClaudeResourcesDocs", () => {
168
173
  });
169
174
 
170
175
  const skillPage = fs.readFileSync(
171
- path.join(docsDir, "claude-skills", "test-skill.mdx"),
176
+ path.join(docsDir, "claude-skills", "test-skill", "index.mdx"),
172
177
  "utf8",
173
178
  );
174
179
 
@@ -179,7 +184,7 @@ describe("generateClaudeResourcesDocs", () => {
179
184
  expect(skillPage).toContain("SKILL.md");
180
185
  });
181
186
 
182
- it("skill page has links to sub-files that resolve correctly from the page URL", () => {
187
+ it("skill page has links to sub-files that resolve to sibling nested files", () => {
183
188
  generateClaudeResourcesDocs({
184
189
  claudeDir,
185
190
  projectRoot: tmpDir,
@@ -187,12 +192,12 @@ describe("generateClaudeResourcesDocs", () => {
187
192
  });
188
193
 
189
194
  const skillPage = fs.readFileSync(
190
- path.join(docsDir, "claude-skills", "test-skill.mdx"),
195
+ path.join(docsDir, "claude-skills", "test-skill", "index.mdx"),
191
196
  "utf8",
192
197
  );
193
198
 
194
- // Links use ./<subpage> format (relative to the skill page URL which
195
- // already includes the skill dir, e.g. /docs/claude-skills/test-skill/)
199
+ // Links use ./<subpage>; the skill page is <dir>/index.mdx so these
200
+ // resolve against the file path to the sibling <dir>/<subpage>.mdx (#2411).
196
201
  expect(skillPage).toContain("./ref-guide");
197
202
  expect(skillPage).toContain("./asset-template");
198
203
 
@@ -200,8 +205,9 @@ describe("generateClaudeResourcesDocs", () => {
200
205
  expect(skillPage).not.toContain("./test-skill/ref-guide");
201
206
  expect(skillPage).not.toContain("./test-skill/asset-template");
202
207
 
203
- // Each linked sub-page must exist as a generated flat .mdx file
204
- // The file is flat (test-skill--ref-guide.mdx) but slug is nested
208
+ // Each linked sub-page must exist as a sibling nested file inside <dir>/,
209
+ // i.e. claude-skills/test-skill/<subpage>.mdx which is exactly where the
210
+ // ./<subpage> relative link resolves to.
205
211
  const linkPattern = /\]\(\.\/([\w-]+)\)/g;
206
212
  let match;
207
213
  while ((match = linkPattern.exec(skillPage)) !== null) {
@@ -209,11 +215,12 @@ describe("generateClaudeResourcesDocs", () => {
209
215
  const targetFile = path.join(
210
216
  docsDir,
211
217
  "claude-skills",
212
- `test-skill--${subPage}.mdx`,
218
+ "test-skill",
219
+ `${subPage}.mdx`,
213
220
  );
214
221
  expect(
215
222
  fs.existsSync(targetFile),
216
- `Link target "test-skill--${subPage}.mdx" should exist`,
223
+ `Link target "test-skill/${subPage}.mdx" should exist`,
217
224
  ).toBe(true);
218
225
  }
219
226
  });
@@ -226,7 +233,7 @@ describe("generateClaudeResourcesDocs", () => {
226
233
  });
227
234
 
228
235
  const skillPage = fs.readFileSync(
229
- path.join(docsDir, "claude-skills", "test-skill.mdx"),
236
+ path.join(docsDir, "claude-skills", "test-skill", "index.mdx"),
230
237
  "utf8",
231
238
  );
232
239
 
@@ -255,28 +262,28 @@ describe("generateClaudeResourcesDocs", () => {
255
262
  // ---------------------------------------------------------------------------
256
263
 
257
264
  describe("sub-file pages", () => {
258
- it("generates unlisted reference page", () => {
265
+ it("generates unlisted reference page as a nested file", () => {
259
266
  generateClaudeResourcesDocs({
260
267
  claudeDir,
261
268
  projectRoot: tmpDir,
262
269
  docsDir,
263
270
  });
264
271
 
265
- const refPage = path.join(docsDir, "claude-skills", "test-skill--ref-guide.mdx");
272
+ const refPage = path.join(docsDir, "claude-skills", "test-skill", "ref-guide.mdx");
266
273
  expect(fs.existsSync(refPage)).toBe(true);
267
274
 
268
275
  const parsed = matter(fs.readFileSync(refPage, "utf8"));
269
276
  expect(parsed.data.unlisted).toBe(true);
270
277
  });
271
278
 
272
- it("generates unlisted asset page for .md files", () => {
279
+ it("generates unlisted asset page for .md files as a nested file", () => {
273
280
  generateClaudeResourcesDocs({
274
281
  claudeDir,
275
282
  projectRoot: tmpDir,
276
283
  docsDir,
277
284
  });
278
285
 
279
- const assetPage = path.join(docsDir, "claude-skills", "test-skill--asset-template.mdx");
286
+ const assetPage = path.join(docsDir, "claude-skills", "test-skill", "asset-template.mdx");
280
287
  expect(fs.existsSync(assetPage)).toBe(true);
281
288
 
282
289
  const parsed = matter(fs.readFileSync(assetPage, "utf8"));
@@ -290,23 +297,26 @@ describe("generateClaudeResourcesDocs", () => {
290
297
  docsDir,
291
298
  });
292
299
 
293
- const scriptPage = path.join(docsDir, "claude-skills", "test-skill--script-run.mdx");
300
+ const scriptPage = path.join(docsDir, "claude-skills", "test-skill", "script-run.mdx");
294
301
  expect(fs.existsSync(scriptPage)).toBe(false);
295
302
  });
296
303
 
297
- it("sub-pages have custom slug for nested breadcrumbs", () => {
304
+ it("sub-pages derive their route from the file path (no explicit slug)", () => {
298
305
  generateClaudeResourcesDocs({
299
306
  claudeDir,
300
307
  projectRoot: tmpDir,
301
308
  docsDir,
302
309
  });
303
310
 
311
+ // The page lives at claude-skills/test-skill/ref-guide.mdx, so its route
312
+ // is claude-skills/test-skill/ref-guide — nested breadcrumbs come from the
313
+ // file path, not an explicit slug (which zfb's link resolver ignores, #2411).
304
314
  const refPage = fs.readFileSync(
305
- path.join(docsDir, "claude-skills", "test-skill--ref-guide.mdx"),
315
+ path.join(docsDir, "claude-skills", "test-skill", "ref-guide.mdx"),
306
316
  "utf8",
307
317
  );
308
318
  const parsed = matter(refPage);
309
- expect(parsed.data.slug).toBe("claude-skills/test-skill/ref-guide");
319
+ expect(parsed.data.slug).toBeUndefined();
310
320
  });
311
321
 
312
322
  it("reference page content is correct", () => {
@@ -317,7 +327,7 @@ describe("generateClaudeResourcesDocs", () => {
317
327
  });
318
328
 
319
329
  const refPage = fs.readFileSync(
320
- path.join(docsDir, "claude-skills", "test-skill--ref-guide.mdx"),
330
+ path.join(docsDir, "claude-skills", "test-skill", "ref-guide.mdx"),
321
331
  "utf8",
322
332
  );
323
333
  const parsed = matter(refPage);
@@ -532,4 +542,109 @@ describe("generateClaudeResourcesDocs", () => {
532
542
  ).toThrow(/reserved name "index"/);
533
543
  });
534
544
  });
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // projectRoot default scope regression test
548
+ // ---------------------------------------------------------------------------
549
+
550
+ describe("projectRoot default scope", () => {
551
+ it("walks claudeDir (not its parent) when projectRoot is omitted", () => {
552
+ // The fixture already writes tmpDir/CLAUDE.md (outside claudeDir).
553
+ // With the old bug (projectRoot = path.dirname(claudeDir) = tmpDir), the
554
+ // walk starts at tmpDir and picks up tmpDir/CLAUDE.md. With the fix
555
+ // (projectRoot = claudeDir), the walk starts inside claudeDir and does NOT
556
+ // reach tmpDir/CLAUDE.md (parent dirs are never walked upward).
557
+ //
558
+ // To distinguish, we write a CLAUDE.md inside claudeDir with unique content
559
+ // and verify the generated page body matches IT, not the outside one.
560
+ fs.writeFileSync(
561
+ path.join(claudeDir, "CLAUDE.md"),
562
+ "# Claude dir unique content xyz123",
563
+ );
564
+
565
+ // Call with NO projectRoot.
566
+ generateClaudeResourcesDocs({ claudeDir, docsDir });
567
+
568
+ // The generated root.mdx body must contain the inside-claudeDir content.
569
+ const claudeMdDir = path.join(docsDir, "claude-md");
570
+ const rootMdx = fs.readFileSync(path.join(claudeMdDir, "root.mdx"), "utf8");
571
+ expect(rootMdx).toContain("xyz123");
572
+
573
+ // And must NOT contain the outside content (from tmpDir/CLAUDE.md).
574
+ expect(rootMdx).not.toContain("Project instructions");
575
+ });
576
+ });
577
+
578
+ // ---------------------------------------------------------------------------
579
+ // CLAUDE.md repo-relative link downgrade (#2411, Class B)
580
+ // ---------------------------------------------------------------------------
581
+
582
+ describe("CLAUDE.md repo-relative link downgrade", () => {
583
+ const claudeMdBody = [
584
+ "# Project",
585
+ "",
586
+ "See [wrangler.toml](./wrangler.toml) and [schema](../../schema/photos.sql).",
587
+ "Also a [workflow](../../.github/workflows/deploy.yml) and a [bare](docs/guide.md) link.",
588
+ "External [site](https://example.com), [anchor](#section), [site-abs](/docs/getting-started), [mail](mailto:x@y.com).",
589
+ "Image: ![diagram](./diagram.png)",
590
+ "",
591
+ "Inline code stays: `[x](./y)`",
592
+ "",
593
+ "```",
594
+ "[code](./should-not-change.md)",
595
+ "```",
596
+ "",
597
+ "~~~",
598
+ "[tilde-code](./also-should-not-change.md)",
599
+ "~~~",
600
+ "",
601
+ ].join("\n");
602
+
603
+ function genRoot() {
604
+ fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), claudeMdBody);
605
+ generateClaudeResourcesDocs({ claudeDir, projectRoot: tmpDir, docsDir });
606
+ return fs.readFileSync(
607
+ path.join(docsDir, "claude-md", "root.mdx"),
608
+ "utf8",
609
+ );
610
+ }
611
+
612
+ it("downgrades repo-relative file links to inline code", () => {
613
+ const rootMdx = genRoot();
614
+ expect(rootMdx).toContain("`wrangler.toml`");
615
+ expect(rootMdx).toContain("`schema`");
616
+ expect(rootMdx).toContain("`workflow`");
617
+ expect(rootMdx).toContain("`bare`");
618
+ // The dangling hrefs must be gone.
619
+ expect(rootMdx).not.toContain("](./wrangler.toml)");
620
+ expect(rootMdx).not.toContain("](../../schema/photos.sql)");
621
+ expect(rootMdx).not.toContain("](../../.github/workflows/deploy.yml)");
622
+ expect(rootMdx).not.toContain("](docs/guide.md)");
623
+ });
624
+
625
+ it("preserves resolvable links (external, anchor, site-absolute, scheme)", () => {
626
+ const rootMdx = genRoot();
627
+ expect(rootMdx).toContain("](https://example.com)");
628
+ expect(rootMdx).toContain("](#section)");
629
+ expect(rootMdx).toContain("](/docs/getting-started)");
630
+ expect(rootMdx).toContain("](mailto:x@y.com)");
631
+ });
632
+
633
+ it("downgrades repo-relative image links without leaving a stray '!'", () => {
634
+ const rootMdx = genRoot();
635
+ expect(rootMdx).toContain("`diagram`");
636
+ expect(rootMdx).not.toContain("](./diagram.png)");
637
+ expect(rootMdx).not.toContain("!`diagram`");
638
+ });
639
+
640
+ it("does not rewrite links inside inline code or fenced code blocks", () => {
641
+ const rootMdx = genRoot();
642
+ // Inline-code span is preserved verbatim.
643
+ expect(rootMdx).toContain("`[x](./y)`");
644
+ // Backtick-fenced code block content is preserved verbatim.
645
+ expect(rootMdx).toContain("[code](./should-not-change.md)");
646
+ // Tilde-fenced code block content is preserved verbatim too.
647
+ expect(rootMdx).toContain("[tilde-code](./also-should-not-change.md)");
648
+ });
649
+ });
535
650
  });
@@ -96,18 +96,24 @@ generated: true
96
96
  }
97
97
 
98
98
  /**
99
- * Writes an unlisted sub-page MDX file (flat file with a custom nested slug).
100
- * Used for skill references, scripts, and assets.
99
+ * Writes an unlisted sub-page MDX file. Used for skill references, scripts,
100
+ * and assets.
101
+ *
102
+ * The route is derived from the file's path within the content collection —
103
+ * deliberately NOT from an explicit `slug:`. zfb's `resolveMarkdownLinks`
104
+ * resolves relative links against the *source file path*, so the on-disk
105
+ * location of these pages must match the URL the skill page links to. Writing
106
+ * them at `<dir>/ref-<name>.mdx` (siblings of the skill's `index.mdx`) is what
107
+ * makes the `./ref-<name>` links resolve (#2411).
101
108
  */
102
109
  function writeUnlistedSubPage(
103
110
  outputPath: string,
104
111
  title: string,
105
- slug: string,
106
112
  body: string,
107
113
  ) {
108
114
  fs.writeFileSync(
109
115
  outputPath,
110
- `---\ntitle: "${escapeTitle(title)}"\nslug: "${slug}"\nunlisted: true\ngenerated: true\n---\n\n${body}\n`,
116
+ `---\ntitle: "${escapeTitle(title)}"\nunlisted: true\ngenerated: true\n---\n\n${body}\n`,
111
117
  );
112
118
  }
113
119
 
@@ -124,6 +130,82 @@ function assertNotIndexReserved(
124
130
  }
125
131
  }
126
132
 
133
+ /**
134
+ * Whether a markdown link target is a repo-relative file reference
135
+ * (`./wrangler.toml`, `../../schema/photos.sql`, `foo/bar.md`) rather than
136
+ * something the doc site can resolve: an absolute URL (`https://…`), a
137
+ * protocol-relative URL (`//…`), a site-absolute path (`/docs/…`), a pure
138
+ * anchor (`#…`), or a scheme (`mailto:`, `tel:`).
139
+ */
140
+ function isRepoRelativeLink(url: string): boolean {
141
+ const trimmed = url.trim();
142
+ if (trimmed === "") return false;
143
+ if (trimmed.startsWith("#")) return false; // anchor
144
+ if (trimmed.startsWith("/")) return false; // site-absolute or protocol-relative (//host)
145
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return false; // has a scheme (http:, mailto:, …)
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Downgrade repo-relative markdown links in a mirrored `CLAUDE.md` body to
151
+ * inline code so they don't dangle in the flattened mirror tree (#2411).
152
+ *
153
+ * A `CLAUDE.md`'s relative links point at real repo files (correct for an
154
+ * in-repo reader), but the mirror flattens each file into a single
155
+ * `claude-md/<name>.mdx` page with no counterpart for those targets — left as
156
+ * links they surface as `broken link:` warnings on every affected page. Inline
157
+ * code keeps the reference legible (`` `wrangler.toml` ``) without a href.
158
+ *
159
+ * Code spans are preserved verbatim: a `[x](./y)` inside a fenced block or an
160
+ * inline-code span is literal text, not a link, and must not be rewritten.
161
+ */
162
+ function downgradeRepoRelativeLinks(content: string): string {
163
+ const blockPlaceholder = "\x00CRLINK_BLOCK_";
164
+ const inlinePlaceholder = "\x00CRLINK_INLINE_";
165
+
166
+ // Extract fenced code blocks so their contents are untouched. Both backtick
167
+ // (```) and tilde (~~~) fences are recognised; the `\1` backreference makes
168
+ // the closing fence match the same delimiter the block opened with.
169
+ const codeBlocks: string[] = [];
170
+ const withBlocks = content.replace(/(`{3,}|~{3,})[^\n]*\n[\s\S]*?\1/g, (match) => {
171
+ codeBlocks.push(match);
172
+ return `${blockPlaceholder}${codeBlocks.length - 1}\x00`;
173
+ });
174
+
175
+ const transformed = withBlocks
176
+ .split(new RegExp(`(${blockPlaceholder}\\d+\x00)`, "g"))
177
+ .map((part) => {
178
+ if (new RegExp(`^${blockPlaceholder}\\d+\x00$`).test(part)) return part;
179
+
180
+ // Preserve inline-code spans, then rewrite links in the remaining text.
181
+ const inlineCodes: string[] = [];
182
+ const withInline = part.replace(
183
+ /(`{1,3})(?!`)([\s\S]*?[^`])\1(?!`)/g,
184
+ (match) => {
185
+ inlineCodes.push(match);
186
+ return `${inlinePlaceholder}${inlineCodes.length - 1}\x00`;
187
+ },
188
+ );
189
+
190
+ const rewritten = withInline.replace(
191
+ /!?\[([^\]]*)\]\(([^)]+)\)/g,
192
+ (match, text: string, url: string) =>
193
+ isRepoRelativeLink(url) ? `\`${text}\`` : match,
194
+ );
195
+
196
+ return rewritten.replace(
197
+ new RegExp(`${inlinePlaceholder}(\\d+)\x00`, "g"),
198
+ (_, idx: string) => inlineCodes[Number(idx)] ?? "",
199
+ );
200
+ })
201
+ .join("");
202
+
203
+ return transformed.replace(
204
+ new RegExp(`${blockPlaceholder}(\\d+)\x00`, "g"),
205
+ (_, idx: string) => codeBlocks[Number(idx)] ?? "",
206
+ );
207
+ }
208
+
127
209
  // ---------------------------------------------------------------------------
128
210
  // CLAUDE.md discovery
129
211
  // ---------------------------------------------------------------------------
@@ -164,7 +246,7 @@ function findClaudeMdFiles(dir: string, excludeDirs: string[]): string[] {
164
246
  function generateClaudemdDocs(
165
247
  config: ClaudeResourcesConfig,
166
248
  ): ClaudeMdItem[] {
167
- const projectRoot = config.projectRoot ?? path.dirname(config.claudeDir);
249
+ const projectRoot = config.projectRoot ?? config.claudeDir;
168
250
  const outputDir = path.join(config.docsDir, "claude-md");
169
251
 
170
252
  cleanDir(outputDir);
@@ -229,7 +311,7 @@ generated: true
229
311
 
230
312
  **Path:** \`${item.relPath}\`
231
313
 
232
- ${escapeForMdx(content.trim())}
314
+ ${escapeForMdx(downgradeRepoRelativeLinks(content.trim()))}
233
315
  `;
234
316
  fs.writeFileSync(path.join(outputDir, `${item.slug}.mdx`), mdx);
235
317
  });
@@ -309,6 +391,7 @@ function getSkillFileTree(
309
391
 
310
392
  for (let i = 0; i < entries.length; i++) {
311
393
  const entry = entries[i];
394
+ if (!entry) continue;
312
395
  const isLast = i === entries.length - 1;
313
396
  const prefix = isLast ? "└── " : "├── ";
314
397
 
@@ -318,6 +401,7 @@ function getSkillFileTree(
318
401
  lines.push(`${prefix}${entry.name}/`);
319
402
  for (let j = 0; j < entry.children.length; j++) {
320
403
  const child = entry.children[j];
404
+ if (!child) continue;
321
405
  const childIsLast = j === entry.children.length - 1;
322
406
  const continuation = isLast ? " " : "│ ";
323
407
  const childPrefix = childIsLast ? "└── " : "├── ";
@@ -333,9 +417,10 @@ function getScriptDescription(filePath: string): string {
333
417
  try {
334
418
  const topLines = fs.readFileSync(filePath, "utf8").split("\n", 2);
335
419
  // Skip shebang, use second line if available
336
- const commentLine = topLines[0].startsWith("#!")
337
- ? topLines[1] || ""
338
- : topLines[0];
420
+ const firstLine = topLines[0] ?? "";
421
+ const commentLine = firstLine.startsWith("#!")
422
+ ? topLines[1] ?? ""
423
+ : firstLine;
339
424
  // Match # comments (shell/python) or // comments (JS/TS)
340
425
  const match = commentLine.match(/^(?:#|\/\/)\s*(.+)/);
341
426
  return match ? ` — ${match[1]}` : "";
@@ -358,7 +443,7 @@ function getSkillReferences(
358
443
  const content = fs.readFileSync(path.join(refsDir, f), "utf8");
359
444
  const name = f.replace(/\.md$/, "");
360
445
  const h1Match = content.match(/^#\s+(.+)$/m);
361
- const title = h1Match ? h1Match[1] : name;
446
+ const title = h1Match?.[1] ?? name;
362
447
  return { name, title, content };
363
448
  })
364
449
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -418,9 +503,9 @@ function generateSkillsDocs(config: ClaudeResourcesConfig): SkillItem[] {
418
503
  if (subDirs.length > 0) {
419
504
  const tree = `\`\`\`\n${getSkillFileTree(dir, subDirs)}\n\`\`\``;
420
505
 
421
- // Collect links to all .md sub-files that get pages
422
- // Links use ./<subpage> which resolves correctly from the skill page URL
423
- // (the page URL already includes the dir, e.g. /docs/claude-skills/<dir>/)
506
+ // Collect links to all .md sub-files that get pages. Links use
507
+ // ./<subpage>; because the skill page is written as `<dir>/index.mdx`,
508
+ // these resolve to the sibling `<dir>/<subpage>.mdx` files (#2411).
424
509
  const links: string[] = [];
425
510
  for (const ref of references) {
426
511
  links.push(`- [references/${ref.name}.md](./ref-${ref.name})`);
@@ -465,18 +550,22 @@ generated: true
465
550
 
466
551
  ${body}`;
467
552
 
468
- // Write skill page as flat file
469
- fs.writeFileSync(path.join(outputDir, `${dir}.mdx`), mdx);
470
-
471
- // Generate unlisted sub-pages (flat files with custom slug for nested breadcrumbs)
472
- // File: <dir>--ref-<name>.mdx, slug: claude-skills/<dir>/ref-<name>
473
- const skillSlugBase = `claude-skills/${dir}`;
474
-
553
+ // Write the skill page as the directory index (`<dir>/index.mdx`) so its
554
+ // route is `claude-skills/<dir>` served at URL `.../claude-skills/<dir>/`.
555
+ // This makes the reference/script/asset pages genuine siblings inside
556
+ // `<dir>/`, which is what lets the `./ref-<name>` links above resolve.
557
+ const skillDirOut = path.join(outputDir, dir);
558
+ ensureDir(skillDirOut);
559
+ fs.writeFileSync(path.join(skillDirOut, "index.mdx"), mdx);
560
+
561
+ // Generate unlisted sub-pages as nested files inside `<dir>/`. Their routes
562
+ // (`claude-skills/<dir>/ref-<name>`, …) are derived from these file paths
563
+ // and therefore match the `./ref-<name>` / `./script-<name>` /
564
+ // `./asset-<name>` links emitted above (#2411).
475
565
  for (const ref of references) {
476
566
  writeUnlistedSubPage(
477
- path.join(outputDir, `${dir}--ref-${ref.name}.mdx`),
567
+ path.join(skillDirOut, `ref-${ref.name}.mdx`),
478
568
  ref.title,
479
- `${skillSlugBase}/ref-${ref.name}`,
480
569
  escapeForMdx(ref.content.trim()),
481
570
  );
482
571
  }
@@ -488,11 +577,10 @@ ${body}`;
488
577
  "utf8",
489
578
  );
490
579
  const h1Match = raw.match(/^#\s+(.+)$/m);
491
- const title = h1Match ? h1Match[1] : slug;
580
+ const title = h1Match?.[1] ?? slug;
492
581
  writeUnlistedSubPage(
493
- path.join(outputDir, `${dir}--script-${slug}.mdx`),
582
+ path.join(skillDirOut, `script-${slug}.mdx`),
494
583
  title,
495
- `${skillSlugBase}/script-${slug}`,
496
584
  escapeForMdx(raw.trim()),
497
585
  );
498
586
  }
@@ -504,11 +592,10 @@ ${body}`;
504
592
  "utf8",
505
593
  );
506
594
  const h1Match = raw.match(/^#\s+(.+)$/m);
507
- const title = h1Match ? h1Match[1] : slug;
595
+ const title = h1Match?.[1] ?? slug;
508
596
  writeUnlistedSubPage(
509
- path.join(outputDir, `${dir}--asset-${slug}.mdx`),
597
+ path.join(skillDirOut, `asset-${slug}.mdx`),
510
598
  title,
511
- `${skillSlugBase}/asset-${slug}`,
512
599
  escapeForMdx(raw.trim()),
513
600
  );
514
601
  }