@takazudo/zudo-doc 0.2.0 → 0.2.2

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 (35) hide show
  1. package/README.md +53 -2
  2. package/dist/doclayout/doc-layout.js +25 -19
  3. package/dist/header/nav-active.d.ts +0 -11
  4. package/dist/header/nav-active.js +7 -2
  5. package/dist/html-preview-wrapper/html-preview-wrapper.d.ts +13 -0
  6. package/dist/html-preview-wrapper/html-preview-wrapper.js +12 -1
  7. package/dist/html-preview-wrapper/html-preview.d.ts +43 -2
  8. package/dist/html-preview-wrapper/html-preview.js +8 -2
  9. package/dist/integrations/claude-resources/generate.js +50 -54
  10. package/dist/integrations/doc-history/pre-build.d.ts +13 -5
  11. package/dist/integrations/doc-history/pre-build.js +11 -28
  12. package/dist/integrations/llms-txt/dev-middleware.d.ts +1 -0
  13. package/dist/integrations/llms-txt/emit.d.ts +1 -0
  14. package/dist/integrations/llms-txt/generate.d.ts +1 -0
  15. package/dist/integrations/llms-txt/index.d.ts +3 -2
  16. package/dist/integrations/llms-txt/load.d.ts +7 -35
  17. package/dist/integrations/llms-txt/load.js +21 -54
  18. package/dist/integrations/llms-txt/strip.d.ts +11 -9
  19. package/dist/integrations/llms-txt/strip.js +1 -3
  20. package/dist/integrations/llms-txt/types.d.ts +7 -20
  21. package/dist/integrations/search-index/collect.js +3 -2
  22. package/dist/md-utils/index.d.ts +76 -0
  23. package/dist/{integrations/search-index/content-files.js → md-utils/index.js} +12 -5
  24. package/dist/safelist.css +1 -1
  25. package/dist/sidebar-tree/category-meta.js +6 -1
  26. package/dist/theme/iframe-bridge.js +5 -3
  27. package/dist/theme/index.d.ts +2 -0
  28. package/dist/theme/theme-toggle.d.ts +7 -6
  29. package/dist/theme/theme-toggle.js +5 -81
  30. package/dist/theme-toggle/color-scheme-sync.d.ts +38 -0
  31. package/dist/theme-toggle/color-scheme-sync.js +22 -0
  32. package/dist/theme-toggle/index.d.ts +12 -0
  33. package/dist/theme-toggle/index.js +83 -0
  34. package/package.json +15 -6
  35. package/dist/integrations/search-index/content-files.d.ts +0 -42
package/README.md CHANGED
@@ -6,6 +6,7 @@ This package provides the missing-by-design framework concerns:
6
6
 
7
7
  - **Sidebar tree builder** (`./sidebar-tree`) — turns collection entries + `_category_.json` into a sidebar `SidebarNode[]`.
8
8
  - **Theme controls** (`./theme`) — color scheme provider + design-token tweak panel (Preact island that wraps an iframe).
9
+ - **Theme toggle (bare)** (`./theme-toggle`) — the un-wrapped ThemeToggle component for call sites that compose their own `<Island>` (the `./theme` barrel exports an Island-wrapped variant of the same component).
9
10
  - **TOC** (`./toc`) — desktop and mobile TOC Preact islands fed by MDX `headings` export.
10
11
  - **Breadcrumb** (`./breadcrumb`) — JSX breadcrumb fed by the sidebar tree.
11
12
  - **DocLayout** (`./doclayout`) — composable layout shell with explicit `<Header>`, `<Sidebar>`, `<Main>`, `<Toc>`, `<Footer>` props; ships a `<DocLayoutWithDefaults>` wrapper that holds the 16 `create-zudo-doc` injection anchors.
@@ -13,6 +14,56 @@ This package provides the missing-by-design framework concerns:
13
14
  - **Head injection** (`./head`) — canonical, og:\*, twitter:\*, robots, preload hints, RSS link, sitemap link, theme-color — byte-equal to today's legacy doc-layout output.
14
15
  - **SSR-skip wrappers** (`./ssr-skip`) — `<AiChatModalIsland>`, `<ImageEnlargeIsland>`, `<DesignTokenTweakPanelIsland>`, `<MockInitIsland>` — wrap zfb's `<Island ssrFallback>` with the right fallback markup so doc pages don't have to re-implement the SSR-skip pattern.
15
16
 
16
- ## Pre-publish dev workflow
17
+ ## Optional peer dependency: shiki
17
18
 
18
- Phase A npm publish is deferred (see super-epic comment 2026-04-28). During pre-publish dev, this package references zfb via the local checkout at `$HOME/repos/myoss/zfb`. Use `/refer-another-project zfb` to read zfb's APIs. If a zfb bug is discovered, fix it directly on zfb's `main` and push (zfb is not public yet).
19
+ `./html-preview-wrapper`'s `<HighlightedCode>` lazily `import("shiki")`s at runtime for client-side syntax highlighting. `shiki` is declared as an **optional peerDependency** install it in your project if you use that subpath:
20
+
21
+ ```sh
22
+ pnpm add shiki
23
+ ```
24
+
25
+ Projects scaffolded by `create-zudo-doc` already include it. If you never render `<HtmlPreview>` / `<HighlightedCode>`, you can omit it.
26
+
27
+ ## ⚠️ HTML preview iframe sandbox — trust assumption
28
+
29
+ `<HtmlPreview>` / `<HtmlPreviewWrapper>` render their preview inside an `<iframe srcdoc>` whose `sandbox` attribute **defaults** to:
30
+
31
+ - `allow-scripts allow-same-origin` when the preview contains scripts (a `js` prop or a `<script>` in `head`), or
32
+ - `allow-same-origin` when it does not.
33
+
34
+ **`allow-scripts` + `allow-same-origin` together effectively void the sandbox** — scripts running inside the preview share the parent page's origin and can reach the parent document. This default is intentional and safe for zudo-doc's own use case, where preview content is **author-trusted** MDX. The `allow-same-origin` token is what lets the component auto-measure the iframe body and sync its height.
35
+
36
+ If your project renders **semi-trusted or user-submitted** HTML in a preview, override the sandbox with a stricter value via the `sandbox` prop:
37
+
38
+ ```tsx
39
+ // Maximally restrictive — no script execution, opaque origin
40
+ <HtmlPreviewWrapper html={untrustedHtml} sandbox="" height={400} />
41
+
42
+ // Allow scripts but keep an opaque origin (script can't reach the parent)
43
+ <HtmlPreviewWrapper html={untrustedHtml} sandbox="allow-scripts" height={400} />
44
+ ```
45
+
46
+ **Caveat:** removing `allow-same-origin` gives the iframe an opaque origin, which blocks the parent from reading `iframe.contentDocument`. That **disables auto-height** — always pair a stricter `sandbox` with a fixed `height`. Passing the empty string `""` is honored verbatim (only omitting the prop falls back to the computed default).
47
+
48
+ ## Styling — Tailwind setup for consumers
49
+
50
+ This package ships **no precompiled CSS** — the component utility classes are inlined in the `dist/` JavaScript, and Tailwind v4 does not scan `node_modules`. Without help, those utilities never make it into your build, so the components render unstyled.
51
+
52
+ The fix is to import the package's build-generated safelist into your Tailwind CSS entry, right next to your `@import "tailwindcss";`:
53
+
54
+ ```css
55
+ @import "tailwindcss";
56
+ @import "@takazudo/zudo-doc/safelist.css";
57
+ ```
58
+
59
+ `dist/safelist.css` is generated at package build time and contains an `@source inline()` set covering every utility the components use (including arbitrary-value classes like `w-[var(--zd-sidebar-w)]`). It auto-syncs whenever you upgrade the package — no drift, no manual maintenance. Available in `@takazudo/zudo-doc` **>= 0.2.0**.
60
+
61
+ > **Don't `@source` into `node_modules`.** A glob like `@source "../node_modules/@takazudo/zudo-doc/dist/**"` looks plausible but is unreliable: pnpm surfaces packages via symlinks and Tailwind v4's file scanner does not reliably traverse them, so utilities get intermittently dropped across rebuilds (see zudolab/zudo-doc#1989). Import the package safelist instead.
62
+
63
+ **Migrating from a pre-0.2.0 workaround?** If you vendored or copied the package `dist/` to get its styles, delete that workaround and replace it with the single `@import "@takazudo/zudo-doc/safelist.css";` line above.
64
+
65
+ ## Dev workflow (in this repo)
66
+
67
+ This package is published to npm as `@takazudo/zudo-doc` (since `0.2.0`, `latest` tracks the current line). Inside this repo the host site consumes it as a workspace package through its compiled `dist/` — `pnpm dev` at the repo root runs `tsup --watch` so edits under `src/` rebuild automatically; for a one-off rebuild use `pnpm --filter @takazudo/zudo-doc build`.
68
+
69
+ zfb itself comes from npm (versions pinned in the root `package.json`). To develop against a local zfb checkout, use the temporary `pnpm.overrides` link escape hatch documented in the root `CLAUDE.md` — do not commit the override.
@@ -59,25 +59,31 @@ function DocLayout(props) {
59
59
  }
60
60
  ),
61
61
  afterSidebar,
62
- /* @__PURE__ */ jsxs("div", { class: showSidebar ? "lg:ml-[var(--zd-sidebar-w)]" : void 0, children: [
63
- /* @__PURE__ */ jsx("div", { class: "flex min-h-[calc(100vh-3.5rem)] justify-center", children: /* @__PURE__ */ jsxs(
64
- "div",
65
- {
66
- class: `flex w-full gap-[clamp(1.5rem,3vw,4rem)] ${showSidebar ? "max-w-[clamp(50rem,75vw,90rem)]" : "max-w-[80rem]"}`,
67
- children: [
68
- /* @__PURE__ */ jsxs("main", { class: "flex-1 min-w-0 px-hsp-xl py-vsp-xl lg:px-hsp-2xl lg:py-vsp-2xl", children: [
69
- breadcrumb,
70
- afterBreadcrumb,
71
- !hideToc && mobileToc,
72
- /* @__PURE__ */ jsx("article", { class: "zd-content max-w-none", children: main }),
73
- afterContent
74
- ] }),
75
- showToc && toc
76
- ]
77
- }
78
- ) }),
79
- footer
80
- ] }),
62
+ /* @__PURE__ */ jsxs(
63
+ "div",
64
+ {
65
+ class: `zd-sidebar-content-wrapper${showSidebar ? " lg:ml-[var(--zd-sidebar-w)]" : ""}`,
66
+ children: [
67
+ /* @__PURE__ */ jsx("div", { class: "flex min-h-[calc(100vh-3.5rem)] justify-center", children: /* @__PURE__ */ jsxs(
68
+ "div",
69
+ {
70
+ class: `zd-doc-content-band flex w-full gap-[clamp(1.5rem,3vw,4rem)] ${showSidebar ? "max-w-[clamp(50rem,75vw,90rem)]" : "max-w-[80rem]"}`,
71
+ children: [
72
+ /* @__PURE__ */ jsxs("main", { class: "flex-1 min-w-0 px-hsp-xl py-vsp-xl lg:px-hsp-2xl lg:py-vsp-2xl", children: [
73
+ breadcrumb,
74
+ afterBreadcrumb,
75
+ !hideToc && mobileToc,
76
+ /* @__PURE__ */ jsx("article", { class: "zd-content max-w-none", children: main }),
77
+ afterContent
78
+ ] }),
79
+ showToc && toc
80
+ ]
81
+ }
82
+ ) }),
83
+ footer
84
+ ]
85
+ }
86
+ ),
81
87
  bodyEndComponents,
82
88
  bodyEndScripts
83
89
  ] })
@@ -17,17 +17,6 @@ interface NavItemLike {
17
17
  * pathForMatch(null, "ja", "en") === ""
18
18
  */
19
19
  declare function pathForMatch(pathWithoutBase: string, lang: string | undefined, defaultLocale: string): string;
20
- /**
21
- * Pick the deepest header-nav path that the current page lives under.
22
- *
23
- * The legacy template flattens parent + child paths, filters by
24
- * `pathForMatch.startsWith(p)`, and picks the longest match — this keeps
25
- * a child link active even when its parent's path is also a prefix of
26
- * the current page.
27
- *
28
- * Returns `undefined` when no nav entry matches; callers should treat
29
- * that as "no active link."
30
- */
31
20
  declare function computeActiveNavPath(navItems: readonly NavItemLike[], pathForMatchValue: string): string | undefined;
32
21
  /**
33
22
  * Active-state predicate for a top-level nav item: the item itself, or
@@ -1,6 +1,11 @@
1
1
  function pathForMatch(pathWithoutBase, lang, defaultLocale) {
2
2
  if (lang == null || lang === defaultLocale) return pathWithoutBase;
3
- return pathWithoutBase.replace(new RegExp(`^/${lang}`), "");
3
+ return pathWithoutBase.replace(new RegExp(`^/${lang}(?=/|$)`), "");
4
+ }
5
+ function pathMatchesNavPath(currentPath, navPath) {
6
+ if (currentPath === navPath) return true;
7
+ const prefix = navPath.endsWith("/") ? navPath : `${navPath}/`;
8
+ return currentPath.startsWith(prefix);
4
9
  }
5
10
  function computeActiveNavPath(navItems, pathForMatchValue) {
6
11
  const allNavPaths = navItems.flatMap((item) => {
@@ -10,7 +15,7 @@ function computeActiveNavPath(navItems, pathForMatchValue) {
10
15
  }
11
16
  return paths;
12
17
  });
13
- return allNavPaths.filter((p) => pathForMatchValue.startsWith(p)).sort((a, b) => b.length - a.length)[0];
18
+ return allNavPaths.filter((p) => pathMatchesNavPath(pathForMatchValue, p)).sort((a, b) => b.length - a.length)[0];
14
19
  }
15
20
  function isNavItemActive(item, activeNavPath) {
16
21
  if (activeNavPath === item.path) return true;
@@ -42,6 +42,19 @@ interface HtmlPreviewWrapperProps {
42
42
  height?: number;
43
43
  /** When true, the code section is expanded by default. */
44
44
  defaultOpen?: boolean;
45
+ /**
46
+ * iframe `sandbox` attribute value, forwarded to `<HtmlPreview>`. Omit to
47
+ * use the computed default (`allow-scripts allow-same-origin` when scripts
48
+ * are present, `allow-same-origin` otherwise).
49
+ *
50
+ * ⚠️ The default voids iframe sandboxing (preview scripts can reach the
51
+ * parent origin) and is only safe for **author-trusted** content.
52
+ * Downstream consumers rendering semi-trusted HTML should pass a stricter
53
+ * value (e.g. `"allow-scripts"` or `""`) — but note that dropping
54
+ * `allow-same-origin` disables auto-height, so pair it with a fixed
55
+ * `height`. See `HtmlPreviewProps.sandbox` for details.
56
+ */
57
+ sandbox?: string;
45
58
  }
46
59
  /**
47
60
  * Bare HTML preview body — the actual island **hydration target**.
@@ -3,7 +3,17 @@ import { jsx } from "preact/jsx-runtime";
3
3
  import { Island } from "@takazudo/zfb";
4
4
  import { HtmlPreview } from "./html-preview.js";
5
5
  function HtmlPreviewWrapperInner(props) {
6
- const { globalConfig, html, css, head, js, title, height, defaultOpen } = props;
6
+ const {
7
+ globalConfig,
8
+ html,
9
+ css,
10
+ head,
11
+ js,
12
+ title,
13
+ height,
14
+ defaultOpen,
15
+ sandbox
16
+ } = props;
7
17
  const mergedHead = [globalConfig?.head, head].filter(Boolean).join("\n") || void 0;
8
18
  const mergedCss = [globalConfig?.css, css].filter(Boolean).join("\n") || void 0;
9
19
  const mergedJs = [globalConfig?.js, js].filter(Boolean).join("\n") || void 0;
@@ -17,6 +27,7 @@ function HtmlPreviewWrapperInner(props) {
17
27
  title,
18
28
  height,
19
29
  defaultOpen,
30
+ sandbox,
20
31
  componentCss: css,
21
32
  componentHead: head,
22
33
  componentJs: js
@@ -11,6 +11,27 @@ interface HtmlPreviewProps {
11
11
  title?: string;
12
12
  height?: number;
13
13
  defaultOpen?: boolean;
14
+ /**
15
+ * iframe `sandbox` attribute value. When omitted, defaults to the value
16
+ * computed from the preview content (`allow-scripts allow-same-origin`
17
+ * when scripts are present, `allow-same-origin` otherwise — see
18
+ * {@link resolveSandbox}).
19
+ *
20
+ * ⚠️ **Trust assumption.** The default keeps `allow-same-origin`, which
21
+ * combined with `allow-scripts` effectively *voids* the iframe sandbox:
22
+ * scripts inside the preview can reach the parent document's origin. This
23
+ * is safe only for **author-trusted** content (e.g. zudo-doc's own
24
+ * MDX-authored previews). Downstream consumers rendering semi-trusted or
25
+ * user-submitted HTML should pass a stricter value (e.g. `"allow-scripts"`
26
+ * or `""`).
27
+ *
28
+ * Note: removing `allow-same-origin` breaks the auto-height mechanism
29
+ * (the parent can no longer read `iframe.contentDocument` to measure the
30
+ * body), so set a fixed {@link HtmlPreviewProps.height} when you do.
31
+ * Passing the empty string `""` (maximally restrictive) is honored —
32
+ * only `undefined` triggers the computed default.
33
+ */
34
+ sandbox?: string;
14
35
  /** Per-component css for code block display (before global merge) */
15
36
  componentCss?: string;
16
37
  /** Per-component head for code block display (before global merge) */
@@ -18,6 +39,26 @@ interface HtmlPreviewProps {
18
39
  /** Per-component js for code block display (before global merge) */
19
40
  componentJs?: string;
20
41
  }
42
+ declare function containsScript(head?: string, js?: string): boolean;
43
+ /**
44
+ * Resolve the iframe `sandbox` attribute value.
45
+ *
46
+ * An explicit `sandbox` prop wins (including the empty string `""`, the most
47
+ * restrictive value — only `undefined` falls through). Otherwise the default
48
+ * is derived from `hasScripts`:
49
+ *
50
+ * - with scripts → `"allow-scripts allow-same-origin"`
51
+ * - without scripts → `"allow-same-origin"`
52
+ *
53
+ * `allow-same-origin` is kept in BOTH defaults on purpose: the parent reads
54
+ * `iframe.contentDocument` for auto-height measurement, and a srcdoc iframe
55
+ * sandboxed without `allow-same-origin` gets an opaque origin that blocks
56
+ * those reads even from the parent page. The combination voids sandboxing
57
+ * for script-bearing previews — acceptable here because preview content is
58
+ * author-trusted MDX. See {@link HtmlPreviewProps.sandbox} for the downstream
59
+ * trust note.
60
+ */
61
+ declare function resolveSandbox(sandbox: string | undefined, hasScripts: boolean): string;
21
62
  /**
22
63
  * HTML preview widget — renders an isolated iframe with viewport
23
64
  * controls and a collapsible code section.
@@ -30,6 +71,6 @@ interface HtmlPreviewProps {
30
71
  * Astro, or wire up the SSR-skip placeholder pattern for non-Astro
31
72
  * consumers.
32
73
  */
33
- declare function HtmlPreview({ html, css, head, js, title, height, defaultOpen, componentCss, componentHead, componentJs, }: HtmlPreviewProps): VNode;
74
+ declare function HtmlPreview({ html, css, head, js, title, height, defaultOpen, sandbox, componentCss, componentHead, componentJs, }: HtmlPreviewProps): VNode;
34
75
 
35
- export { HtmlPreview, type HtmlPreviewProps };
76
+ export { HtmlPreview, type HtmlPreviewProps, containsScript, resolveSandbox };
@@ -8,6 +8,9 @@ function containsScript(head, js) {
8
8
  if (head && /<script/i.test(head)) return true;
9
9
  return false;
10
10
  }
11
+ function resolveSandbox(sandbox, hasScripts) {
12
+ return sandbox ?? (hasScripts ? "allow-scripts allow-same-origin" : "allow-same-origin");
13
+ }
11
14
  function buildSrcdoc(html, css, head, js) {
12
15
  return `<!doctype html>
13
16
  <html>
@@ -31,6 +34,7 @@ function HtmlPreview({
31
34
  title,
32
35
  height,
33
36
  defaultOpen,
37
+ sandbox,
34
38
  componentCss,
35
39
  componentHead,
36
40
  componentJs
@@ -41,7 +45,7 @@ function HtmlPreview({
41
45
  );
42
46
  const hasScripts = containsScript(head, js);
43
47
  const syncDelay = hasScripts ? 300 : 0;
44
- const sandboxValue = hasScripts ? "allow-scripts allow-same-origin" : "allow-same-origin";
48
+ const sandboxValue = resolveSandbox(sandbox, hasScripts);
45
49
  const codeBlocks = useMemo(
46
50
  () => [
47
51
  { language: "html", title: "HTML", code: dedent(html) },
@@ -71,5 +75,7 @@ function HtmlPreview({
71
75
  );
72
76
  }
73
77
  export {
74
- HtmlPreview
78
+ HtmlPreview,
79
+ containsScript,
80
+ resolveSandbox
75
81
  };
@@ -19,7 +19,7 @@ function parseFrontmatter(content) {
19
19
  }
20
20
  }
21
21
  function escapeTitle(s) {
22
- return s.replace(/"/g, '\\"');
22
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
23
23
  }
24
24
  function listFiles(dir) {
25
25
  if (!fs.existsSync(dir)) return [];
@@ -36,6 +36,25 @@ generated: true
36
36
  `;
37
37
  fs.writeFileSync(path.join(outputDir, "index.mdx"), mdx);
38
38
  }
39
+ function writeUnlistedSubPage(outputPath, title, slug, body) {
40
+ fs.writeFileSync(
41
+ outputPath,
42
+ `---
43
+ title: "${escapeTitle(title)}"
44
+ slug: "${slug}"
45
+ unlisted: true
46
+ generated: true
47
+ ---
48
+
49
+ ${body}
50
+ `
51
+ );
52
+ }
53
+ function assertNotIndexReserved(nameOrSlug, errorMessage) {
54
+ if (nameOrSlug === "index") {
55
+ throw new Error(errorMessage);
56
+ }
57
+ }
39
58
  function findClaudeMdFiles(dir, excludeDirs) {
40
59
  const results = [];
41
60
  if (!fs.existsSync(dir)) return results;
@@ -92,11 +111,10 @@ function generateClaudemdDocs(config) {
92
111
  });
93
112
  const emittedSlugs = /* @__PURE__ */ new Map();
94
113
  items.forEach((item, index) => {
95
- if (item.slug === "index") {
96
- throw new Error(
97
- `claude-resources: "${item.relPath}" maps to the reserved slug "index", which is used for the category metadata file. Rename the directory to resolve the conflict.`
98
- );
99
- }
114
+ assertNotIndexReserved(
115
+ item.slug,
116
+ `claude-resources: "${item.relPath}" maps to the reserved slug "index", which is used for the category metadata file. Rename the directory to resolve the conflict.`
117
+ );
100
118
  const previous = emittedSlugs.get(item.slug);
101
119
  if (previous !== void 0) {
102
120
  throw new Error(
@@ -136,11 +154,10 @@ function generateCommandsDocs(config) {
136
154
  const parsed = parseFrontmatter(content);
137
155
  if (!parsed) continue;
138
156
  const name = file.replace(/\.md$/, "");
139
- if (name === "index") {
140
- throw new Error(
141
- `claude-resources: ".claude/commands/index.md" uses the reserved name "index", which is used for the category metadata file. Rename the command file to resolve the conflict.`
142
- );
143
- }
157
+ assertNotIndexReserved(
158
+ name,
159
+ `claude-resources: ".claude/commands/index.md" uses the reserved name "index", which is used for the category metadata file. Rename the command file to resolve the conflict.`
160
+ );
144
161
  const description = parsed.data.description || "";
145
162
  items.push({ name, description });
146
163
  const mdx = `---
@@ -220,11 +237,10 @@ function generateSkillsDocs(config) {
220
237
  ensureDir(outputDir);
221
238
  const items = [];
222
239
  for (const dir of dirs) {
223
- if (dir === "index") {
224
- throw new Error(
225
- `claude-resources: skill directory ".claude/skills/index/" uses the reserved name "index", which is used for the category metadata file. Rename the skill directory to resolve the conflict.`
226
- );
227
- }
240
+ assertNotIndexReserved(
241
+ dir,
242
+ `claude-resources: skill directory ".claude/skills/index/" uses the reserved name "index", which is used for the category metadata file. Rename the skill directory to resolve the conflict.`
243
+ );
228
244
  const content = fs.readFileSync(
229
245
  path.join(skillsDir, dir, "SKILL.md"),
230
246
  "utf8"
@@ -284,60 +300,41 @@ ${body}`;
284
300
  fs.writeFileSync(path.join(outputDir, `${dir}.mdx`), mdx);
285
301
  const skillSlugBase = `claude-skills/${dir}`;
286
302
  for (const ref of references) {
287
- const subSlug = `${skillSlugBase}/ref-${ref.name}`;
288
- const refMdx = `---
289
- title: "${escapeTitle(ref.title)}"
290
- slug: "${subSlug}"
291
- unlisted: true
292
- generated: true
293
- ---
294
-
295
- ${escapeForMdx(ref.content.trim())}
296
- `;
297
- fs.writeFileSync(path.join(outputDir, `${dir}--ref-${ref.name}.mdx`), refMdx);
303
+ writeUnlistedSubPage(
304
+ path.join(outputDir, `${dir}--ref-${ref.name}.mdx`),
305
+ ref.title,
306
+ `${skillSlugBase}/ref-${ref.name}`,
307
+ escapeForMdx(ref.content.trim())
308
+ );
298
309
  }
299
310
  for (const f of scriptFiles.filter((s) => s.endsWith(".md"))) {
300
311
  const slug = f.replace(/\.md$/, "");
301
- const subSlug = `${skillSlugBase}/script-${slug}`;
302
312
  const raw = fs.readFileSync(
303
313
  path.join(skillsDir, dir, "scripts", f),
304
314
  "utf8"
305
315
  );
306
316
  const h1Match = raw.match(/^#\s+(.+)$/m);
307
317
  const title = h1Match?.[1] ?? slug;
308
- fs.writeFileSync(
318
+ writeUnlistedSubPage(
309
319
  path.join(outputDir, `${dir}--script-${slug}.mdx`),
310
- `---
311
- title: "${escapeTitle(title)}"
312
- slug: "${subSlug}"
313
- unlisted: true
314
- generated: true
315
- ---
316
-
317
- ${escapeForMdx(raw.trim())}
318
- `
320
+ title,
321
+ `${skillSlugBase}/script-${slug}`,
322
+ escapeForMdx(raw.trim())
319
323
  );
320
324
  }
321
325
  for (const f of assetFiles.filter((a) => a.endsWith(".md"))) {
322
326
  const slug = f.replace(/\.md$/, "");
323
- const subSlug = `${skillSlugBase}/asset-${slug}`;
324
327
  const raw = fs.readFileSync(
325
328
  path.join(skillsDir, dir, "assets", f),
326
329
  "utf8"
327
330
  );
328
331
  const h1Match = raw.match(/^#\s+(.+)$/m);
329
332
  const title = h1Match?.[1] ?? slug;
330
- fs.writeFileSync(
333
+ writeUnlistedSubPage(
331
334
  path.join(outputDir, `${dir}--asset-${slug}.mdx`),
332
- `---
333
- title: "${escapeTitle(title)}"
334
- slug: "${subSlug}"
335
- unlisted: true
336
- generated: true
337
- ---
338
-
339
- ${escapeForMdx(raw.trim())}
340
- `
335
+ title,
336
+ `${skillSlugBase}/asset-${slug}`,
337
+ escapeForMdx(raw.trim())
341
338
  );
342
339
  }
343
340
  }
@@ -362,11 +359,10 @@ function generateAgentsDocs(config) {
362
359
  const description = parsed.data.description || "";
363
360
  const model = parsed.data.model || "";
364
361
  const fileSlug = file.replace(/\.md$/, "");
365
- if (fileSlug === "index") {
366
- throw new Error(
367
- `claude-resources: ".claude/agents/index.md" uses the reserved name "index", which is used for the category metadata file. Rename the agent file to resolve the conflict.`
368
- );
369
- }
362
+ assertNotIndexReserved(
363
+ fileSlug,
364
+ `claude-resources: ".claude/agents/index.md" uses the reserved name "index", which is used for the category metadata file. Rename the agent file to resolve the conflict.`
365
+ );
370
366
  items.push({ name, file: fileSlug, description, model });
371
367
  const modelBadge = model ? `**Model:** \`${model}\`
372
368
  ` : "";
@@ -39,15 +39,23 @@ interface DocHistoryMetaEntry {
39
39
  author: string;
40
40
  createdDate: string;
41
41
  updatedDate: string;
42
+ /**
43
+ * Source file extension (".mdx" or ".md") — the content walkers accept
44
+ * both (`collectContentFiles` matches `\.mdx?$`), so the view-source URL
45
+ * builder in the host's `_doc-history-area.tsx` reads this instead of
46
+ * hardcoding ".mdx". Optional in older manifests; readers fall back to
47
+ * ".mdx" when absent.
48
+ */
49
+ ext?: ".mdx" | ".md";
42
50
  }
43
51
  /** Manifest shape — keyed by composedSlug. */
44
52
  type DocHistoryMetaManifest = Record<string, DocHistoryMetaEntry>;
45
53
  /**
46
- * Tiny in-file semaphore for bounded parallelism avoids a p-limit dependency.
47
- * Limits concurrent async tasks to `concurrency` at a time.
48
- * Ported from packages/doc-history-server/src/cli.ts.
54
+ * Derive the manifest `ext` value from a content file path. The walkers
55
+ * only collect `.md` / `.mdx` files (`collectContentFiles` matches
56
+ * `\.mdx?$`), so anything not ending in ".md" is ".mdx".
49
57
  */
50
- declare function makeSemaphore(concurrency: number): () => Promise<() => void>;
58
+ declare function deriveSourceExt(filePath: string): ".mdx" | ".md";
51
59
  /**
52
60
  * Emit `<projectRoot>/.zfb/doc-history-meta.json` from git history.
53
61
  *
@@ -61,4 +69,4 @@ declare function makeSemaphore(concurrency: number): () => Promise<() => void>;
61
69
  */
62
70
  declare function runDocHistoryMetaStep(options: RunDocHistoryMetaOptions): Promise<void>;
63
71
 
64
- export { type DocHistoryMetaEntry, type DocHistoryMetaLocaleConfig, type DocHistoryMetaManifest, type DocHistoryMetaVersionConfig, type RunDocHistoryMetaOptions, makeSemaphore, runDocHistoryMetaStep };
72
+ export { type DocHistoryMetaEntry, type DocHistoryMetaLocaleConfig, type DocHistoryMetaManifest, type DocHistoryMetaVersionConfig, type RunDocHistoryMetaOptions, deriveSourceExt, runDocHistoryMetaStep };
@@ -1,36 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { cpus } from "node:os";
4
3
  import {
5
4
  collectContentFiles,
6
5
  getFileCommitsMetaAsync
7
6
  } from "@takazudo/zudo-doc-history-server/git-history";
7
+ import {
8
+ makeSemaphore,
9
+ defaultGitConcurrency
10
+ } from "@takazudo/zudo-doc-history-server/concurrency";
8
11
  const META_OUT_RELATIVE_DIR = ".zfb";
9
12
  const META_OUT_FILENAME = "doc-history-meta.json";
10
- function makeSemaphore(concurrency) {
11
- let running = 0;
12
- const queue = [];
13
- function next() {
14
- if (queue.length > 0 && running < concurrency) {
15
- queue.shift()();
16
- }
17
- }
18
- return function acquire() {
19
- return new Promise((resolve) => {
20
- function tryRun() {
21
- if (running < concurrency) {
22
- running++;
23
- resolve(() => {
24
- running--;
25
- next();
26
- });
27
- } else {
28
- queue.push(tryRun);
29
- }
30
- }
31
- tryRun();
32
- });
33
- };
13
+ function deriveSourceExt(filePath) {
14
+ return filePath.endsWith(".md") ? ".md" : ".mdx";
34
15
  }
35
16
  async function runDocHistoryMetaStep(options) {
36
17
  const projectRoot = path.resolve(options.projectRoot);
@@ -53,7 +34,7 @@ async function runDocHistoryMetaStep(options) {
53
34
  dirEntries.push([code, path.resolve(projectRoot, locale.dir)]);
54
35
  }
55
36
  }
56
- const concurrency = Math.min(8, Math.max(2, cpus().length));
37
+ const concurrency = defaultGitConcurrency();
57
38
  const acquire = makeSemaphore(concurrency);
58
39
  const jobs = [];
59
40
  for (const [localeKey, contentDir] of dirEntries) {
@@ -90,7 +71,9 @@ async function runDocHistoryMetaStep(options) {
90
71
  author: result.oldest.author,
91
72
  // createdDate = oldest commit; updatedDate = newest commit.
92
73
  createdDate: result.oldest.date,
93
- updatedDate: result.newest.date
74
+ updatedDate: result.newest.date,
75
+ // Source extension for the view-source URL (".md" walkers accepted).
76
+ ext: deriveSourceExt(jobs[i].filePath)
94
77
  };
95
78
  }
96
79
  fs.mkdirSync(zfbDir, { recursive: true });
@@ -108,6 +91,6 @@ const defaultLogger = {
108
91
  }
109
92
  };
110
93
  export {
111
- makeSemaphore,
94
+ deriveSourceExt,
112
95
  runDocHistoryMetaStep
113
96
  };
@@ -1,5 +1,6 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { LlmsTxtSiteMeta, LlmsTxtLocaleConfig } from './types.js';
3
+ import '../../md-utils/index.js';
3
4
 
4
5
  /** Connect-style middleware signature — works as a Vite plugin middleware. */
5
6
  type LlmsTxtNextFn = (err?: unknown) => void;
@@ -1,4 +1,5 @@
1
1
  import { LlmsTxtEmitOptions, LlmsTxtEmitResult } from './types.js';
2
+ import '../../md-utils/index.js';
2
3
 
3
4
  /**
4
5
  * Build-time emitter that writes `llms.txt` and `llms-full.txt` for the
@@ -1,4 +1,5 @@
1
1
  import { LlmsDocEntry, LlmsTxtSiteMeta } from './types.js';
2
+ import '../../md-utils/index.js';
2
3
 
3
4
  /**
4
5
  * Pure string generators for `llms.txt` and `llms-full.txt`.
@@ -1,7 +1,8 @@
1
1
  export { LlmsTxtDevMiddlewareOptions, LlmsTxtMiddleware, LlmsTxtMiddlewareLogger, LlmsTxtNextFn, createLlmsTxtDevMiddleware } from './dev-middleware.js';
2
2
  export { emitLlmsTxt } from './emit.js';
3
3
  export { generateLlmsFullTxt, generateLlmsTxt } from './generate.js';
4
- export { collectMdFiles, isExcluded, loadDocEntries, parseMarkdownFile, slugToUrl } from './load.js';
5
- export { stripImportsAndJsx, stripMarkdown } from './strip.js';
4
+ export { loadDocEntries } from './load.js';
5
+ export { stripImportsAndJsx } from './strip.js';
6
6
  export { LlmsDocEntry, LlmsTxtEmitOptions, LlmsTxtEmitResult, LlmsTxtFrontmatter, LlmsTxtLoadOptions, LlmsTxtLocaleConfig, LlmsTxtLogger, LlmsTxtSiteMeta } from './types.js';
7
+ export { collectMdFiles, isExcluded, parseMarkdownFile, slugToUrl, stripMarkdown } from '../../md-utils/index.js';
7
8
  import 'node:http';