create-zudo-doc 1.2.0 → 2.0.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 (48) hide show
  1. package/dist/api.d.ts +6 -0
  2. package/dist/api.js +5 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +4 -0
  5. package/dist/index.js +26 -1
  6. package/dist/scaffold.d.ts +1 -1
  7. package/dist/scaffold.js +10 -6
  8. package/dist/utils.d.ts +27 -0
  9. package/dist/utils.js +72 -1
  10. package/package.json +1 -1
  11. package/templates/base/pages/index.tsx +6 -4
  12. package/templates/base/pages/lib/_chrome.ts +166 -0
  13. package/templates/base/pages/lib/_doc-route-entries.ts +7 -51
  14. package/templates/base/pages/lib/_nav-source-docs.ts +9 -29
  15. package/templates/base/pages/lib/_route-context.ts +32 -0
  16. package/templates/base/src/config/tag-vocabulary-types.ts +4 -39
  17. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +137 -22
  18. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +115 -28
  19. package/templates/features/i18n/files/pages/[locale]/index.tsx +6 -4
  20. package/templates/base/pages/_mdx-components.ts +0 -106
  21. package/templates/base/pages/lib/_category-nav.tsx +0 -34
  22. package/templates/base/pages/lib/_category-tree-nav.tsx +0 -45
  23. package/templates/base/pages/lib/_compose-meta-title.ts +0 -25
  24. package/templates/base/pages/lib/_doc-body-end.tsx +0 -8
  25. package/templates/base/pages/lib/_doc-content-header.tsx +0 -17
  26. package/templates/base/pages/lib/_doc-history-area.tsx +0 -34
  27. package/templates/base/pages/lib/_doc-metainfo-area.tsx +0 -26
  28. package/templates/base/pages/lib/_doc-page-renderer.tsx +0 -31
  29. package/templates/base/pages/lib/_doc-page-shell.tsx +0 -29
  30. package/templates/base/pages/lib/_doc-pager.tsx +0 -9
  31. package/templates/base/pages/lib/_doc-route-paths.ts +0 -16
  32. package/templates/base/pages/lib/_doc-tags-area.tsx +0 -30
  33. package/templates/base/pages/lib/_footer-with-defaults.tsx +0 -58
  34. package/templates/base/pages/lib/_head-with-defaults.tsx +0 -22
  35. package/templates/base/pages/lib/_header-with-defaults.tsx +0 -45
  36. package/templates/base/pages/lib/_inline-version-switcher.tsx +0 -23
  37. package/templates/base/pages/lib/_math-block.tsx +0 -4
  38. package/templates/base/pages/lib/_nav-data-prep.ts +0 -118
  39. package/templates/base/pages/lib/_search-widget-script.ts +0 -2
  40. package/templates/base/pages/lib/_sidebar-prepaint.tsx +0 -11
  41. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +0 -26
  42. package/templates/base/pages/lib/_site-tree-nav.tsx +0 -37
  43. package/templates/base/pages/lib/_toc-title.ts +0 -3
  44. package/templates/base/pages/lib/route-enumerators.ts +0 -58
  45. package/templates/base/src/components/tree-nav-shared.tsx +0 -71
  46. package/templates/base/src/utils/content-files.ts +0 -110
  47. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +0 -47
  48. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +0 -33
@@ -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
  }
@@ -32,10 +32,12 @@ import type { VNode } from "preact";
32
32
  import { Island } from "@takazudo/zfb";
33
33
  import { SiteTreeNav } from "@takazudo/zudo-doc/site-tree-nav-island";
34
34
  import { resolveNavSource } from "../lib/_nav-source-docs";
35
- import { FooterWithDefaults } from "../lib/_footer-with-defaults";
36
- import { HeaderWithDefaults } from "../lib/_header-with-defaults";
37
- import { HeadWithDefaults } from "../lib/_head-with-defaults";
38
- import { composeMetaTitle } from "../lib/_compose-meta-title";
35
+ import {
36
+ FooterWithDefaults,
37
+ HeaderWithDefaults,
38
+ HeadWithDefaults,
39
+ composeMetaTitle,
40
+ } from "../lib/_chrome";
39
41
  import { BodyEndIslands } from "../lib/_body-end-islands";
40
42
 
41
43
  export const frontmatter = { title: "Home" };
@@ -1,106 +0,0 @@
1
- // Project MDX-components assembly — thin wrapper over the package factory.
2
- //
3
- // The bulk of the components bag (htmlOverrides, admonitions, CodeGroup,
4
- // Tabs/TabItem, MathBlock, the img base-rewrite, the enlargeable-`p` override)
5
- // now ships from `@takazudo/zudo-doc/mdx-components` (package-first migration,
6
- // epic #2321, S8 #2332). This file keeps ONLY what is genuinely project-bound:
7
- //
8
- // - the 4 locale-bound nav wrappers (CategoryNav / CategoryTreeNav /
9
- // SiteTreeNav / SiteTreeNavDemo) — they depend on the project's content
10
- // collections + nav-tree utilities, so they cannot live in the package.
11
- // They are passed into the factory as `navData`; the factory injects the
12
- // active `locale` so `/ja` resolves the JA collection (the load-bearing
13
- // locale thread — a static global mdx-components slot would render the EN
14
- // tree on every `/ja` page, which is why this stays a per-call factory).
15
- // - project `extras` spread last: HtmlPreview (with the host's global
16
- // config), Details, the Island SSR pass-through, the PresetGenerator SSR
17
- // shell, and the pure-showcase stubs (Avatar/Button/Card/MyComponent/
18
- // PageLayout) that appear only as illustrative MDX prose.
19
-
20
- import type { ComponentChildren } from "preact";
21
- import { createMdxComponents as createMdxComponentsBase } from "@takazudo/zudo-doc/mdx-components";
22
- import { HtmlPreviewWrapper, type HtmlPreviewWrapperProps } from "@takazudo/zudo-doc/html-preview-wrapper";
23
- import { defaultLocale, type Locale } from "@/config/i18n";
24
- import { settings } from "@/config/settings";
25
- import { CategoryNavWrapper } from "./lib/_category-nav";
26
- import { CategoryTreeNavWrapper } from "./lib/_category-tree-nav";
27
- import { SiteTreeNavWrapper } from "./lib/_site-tree-nav";
28
- import { DetailsWrapper } from "./lib/_details";
29
- import { PresetGeneratorFallback } from "./lib/_preset-generator";
30
-
31
- const HtmlPreviewWithGlobalConfig = (props: HtmlPreviewWrapperProps) =>
32
- HtmlPreviewWrapper({ globalConfig: settings.htmlPreview ?? null, ...props });
33
-
34
- /**
35
- * MDX-tag stub: renders nothing. Returning `null` keeps the rendered tree
36
- * intact (Preact's null-vnode path) without leaking placeholder markup into
37
- * the SSR output.
38
- */
39
- const MdxStub = (_props: unknown) => null;
40
-
41
- /**
42
- * SSR-pass-through wrapper for `<Island when="load|idle|visible">`.
43
- *
44
- * In the zfb build the zfb `<Island>` component is unavailable here, so the
45
- * MDX corpus tag resolves to this binding instead. Rendering the children
46
- * directly ensures server-renderable content nested inside `<Island>`
47
- * (headings, paragraphs, etc.) appears in the SSR HTML. The `when` prop is
48
- * ignored at render time — it is only meaningful to the zfb hydration runtime
49
- * on the client, which reads `data-when` on the inner SSR-skip placeholder.
50
- */
51
- function IslandWrapper(props: {
52
- when?: "load" | "idle" | "visible" | "media";
53
- children?: ComponentChildren;
54
- }): ComponentChildren {
55
- return props.children ?? null;
56
- }
57
-
58
- /**
59
- * Build a locale-aware MDX components map for the given locale.
60
- *
61
- * Delegates the package-resident components to the `@takazudo/zudo-doc`
62
- * factory and supplies the project-bound pieces:
63
- * - `navData`: the 4 locale-aware nav wrappers (the factory injects `lang`).
64
- * - `extras`: HtmlPreview (host-configured), Details, Island pass-through,
65
- * PresetGenerator SSR shell, and the showcase stubs.
66
- *
67
- * Page modules should call createMdxComponents(locale) — not the static
68
- * mdxComponents export — so each render gets the locale-correct map.
69
- */
70
- export function createMdxComponents(lang: Locale | string = defaultLocale) {
71
- return createMdxComponentsBase({
72
- settings,
73
- locale: lang,
74
- navData: {
75
- CategoryNav: CategoryNavWrapper as unknown as (props: Record<string, unknown>) => unknown,
76
- CategoryTreeNav: CategoryTreeNavWrapper as unknown as (props: Record<string, unknown>) => unknown,
77
- SiteTreeNav: SiteTreeNavWrapper as unknown as (props: Record<string, unknown>) => unknown,
78
- },
79
- extras: {
80
- HtmlPreview: HtmlPreviewWithGlobalConfig,
81
- Details: DetailsWrapper,
82
- // SmartBreak: corpus tag with no visual rendering — render nothing.
83
- SmartBreak: MdxStub,
84
- // Island: pass children through so server-renderable content nested
85
- // inside <Island> appears in SSR HTML. See IslandWrapper comment above.
86
- Island: IslandWrapper,
87
- // PresetGenerator: SSR fallback shell that renders the 8 section headings;
88
- // the interactive form hydrates client-side via the SSR-skip placeholder
89
- // inside PresetGeneratorFallback (see pages/lib/_preset-generator.tsx).
90
- PresetGenerator: PresetGeneratorFallback,
91
- // Pure showcase placeholders — appear only inside MDX prose as
92
- // illustrative examples, never implemented as real components.
93
- Avatar: MdxStub,
94
- Button: MdxStub,
95
- Card: MdxStub,
96
- MyComponent: MdxStub,
97
- PageLayout: MdxStub,
98
- },
99
- });
100
- }
101
-
102
- /**
103
- * Static default-locale components map for backward compatibility.
104
- * New page modules should call createMdxComponents(locale) instead.
105
- */
106
- export const mdxComponents = createMdxComponents(defaultLocale);