create-zudo-doc 0.2.0 → 0.2.1

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 (72) hide show
  1. package/dist/api.js +4 -1
  2. package/dist/cli.js +4 -6
  3. package/dist/preset.js +11 -0
  4. package/dist/prompts.js +2 -6
  5. package/dist/scaffold.js +15 -9
  6. package/dist/settings-gen.js +7 -7
  7. package/dist/utils.d.ts +8 -0
  8. package/dist/utils.js +25 -0
  9. package/dist/zfb-config-gen.js +11 -50
  10. package/package.json +1 -1
  11. package/templates/base/pages/_data.ts +10 -23
  12. package/templates/base/pages/docs/[[...slug]].tsx +27 -168
  13. package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
  14. package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
  15. package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
  16. package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
  17. package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
  18. package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
  19. package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
  20. package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
  21. package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
  22. package/templates/base/pages/lib/_header-with-defaults.tsx +51 -89
  23. package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
  24. package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
  25. package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
  26. package/templates/base/pages/lib/_search-widget-script.ts +32 -9
  27. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
  28. package/templates/base/pages/lib/locale-merge.ts +1 -1
  29. package/templates/base/pages/lib/route-enumerators.ts +11 -7
  30. package/templates/base/plugins/connect-adapter.mjs +30 -1
  31. package/templates/base/plugins/copy-public-plugin.mjs +10 -2
  32. package/templates/base/plugins/search-index-plugin.mjs +20 -8
  33. package/templates/base/src/components/sidebar-toggle.tsx +1 -1
  34. package/templates/base/src/components/sidebar-tree.tsx +10 -4
  35. package/templates/base/src/config/color-schemes.ts +4 -0
  36. package/templates/base/src/config/docs-schema.ts +94 -0
  37. package/templates/base/src/config/i18n.ts +10 -3
  38. package/templates/base/src/styles/global.css +14 -0
  39. package/templates/base/src/types/docs-entry.ts +8 -26
  40. package/templates/base/src/utils/base.ts +5 -3
  41. package/templates/base/src/utils/docs.ts +144 -169
  42. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
  43. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
  44. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
  45. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
  46. package/templates/features/docHistory/files/src/components/doc-history.tsx +28 -8
  47. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
  48. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
  49. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
  50. package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
  51. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
  52. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
  53. package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
  54. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
  55. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
  56. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
  57. package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
  58. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
  59. package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
  60. package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
  61. package/templates/base/src/components/content/heading-h3.tsx +0 -20
  62. package/templates/base/src/components/theme-toggle.tsx +0 -107
  63. package/templates/base/src/hooks/use-active-heading.ts +0 -133
  64. package/templates/base/src/plugins/docs-source-map.ts +0 -103
  65. package/templates/base/src/plugins/hast-utils.ts +0 -10
  66. package/templates/base/src/plugins/rehype-code-title.ts +0 -50
  67. package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
  68. package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
  69. package/templates/base/src/plugins/url-utils.ts +0 -4
  70. package/templates/base/src/utils/dedent.ts +0 -24
  71. package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
  72. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +0 -198
@@ -1,22 +1,24 @@
1
1
  import type { DocsEntry } from "@/types/docs-entry";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { toTitleCase, toRouteSlug } from "@/utils/slug";
5
2
  import { docsUrl, withBase } from "@/utils/base";
6
3
  import { defaultLocale, type Locale } from "@/config/i18n";
4
+ import {
5
+ buildSidebarTree,
6
+ type CategoryMeta,
7
+ type SidebarNode,
8
+ } from "@takazudo/zudo-doc/sidebar-tree";
7
9
 
8
10
  /** Filter predicate: true when a doc should appear in navigation (sidebar, index, sitemap). */
9
11
  export function isNavVisible(doc: DocsEntry): boolean {
10
12
  return !doc.data.unlisted && !doc.data.standalone;
11
13
  }
12
14
 
13
- export interface CategoryMeta {
14
- label?: string;
15
- position?: number;
16
- description?: string;
17
- sortOrder?: "asc" | "desc";
18
- noPage?: boolean;
19
- }
15
+ // `_category_.json` loading + per-directory memoization live in the shared
16
+ // framework package — the host keeps no parallel copy (#2030 dedup). The
17
+ // package memoizes per resolved (absolute) contentDir, so the returned Map
18
+ // instance is stable per directory — which `stableMergeCategoryMeta` and the
19
+ // identity fast-path below rely on.
20
+ export { loadCategoryMeta } from "@takazudo/zudo-doc/sidebar-tree";
21
+ export type { CategoryMeta } from "@takazudo/zudo-doc/sidebar-tree";
20
22
 
21
23
  export interface NavNode {
22
24
  slug: string;
@@ -30,16 +32,39 @@ export interface NavNode {
30
32
  collapsed?: boolean;
31
33
  }
32
34
 
33
- interface BuildNode {
34
- segment: string;
35
- fullPath: string;
36
- doc?: DocsEntry;
37
- children: Map<string, BuildNode>;
35
+ // Module-level cache — persists across all page renders during a single build.
36
+ //
37
+ // Bounded LRU, cap 64. A production build only ever needs a handful of
38
+ // distinct keys — one per (locale × version × categoryMeta-variant), single
39
+ // digits for this corpus — so 64 never evicts within one build. The cap
40
+ // exists for dev: every content edit under HMR produces a NEW content key
41
+ // (the key embeds nav-affecting frontmatter by design, so edits bust the
42
+ // cache), and before #2030 the Map grew unbounded across a long dev session,
43
+ // each entry holding an O(corpus) key string plus a full tree. With the cap,
44
+ // stale generations age out once 64 fresher keys land. (#1902's WeakMap
45
+ // identity fast-path keeps production hits off this Map entirely.)
46
+ const NAV_TREE_CACHE_MAX = 64;
47
+ const navTreeCache = new Map<string, NavNode[]>();
48
+
49
+ function navTreeCacheGet(key: string): NavNode[] | undefined {
50
+ const hit = navTreeCache.get(key);
51
+ if (hit !== undefined) {
52
+ // Refresh recency — Map preserves insertion order, so delete+set moves
53
+ // the entry to the "most recently used" end.
54
+ navTreeCache.delete(key);
55
+ navTreeCache.set(key, hit);
56
+ }
57
+ return hit;
38
58
  }
39
59
 
40
- // Module-level caches persist across all page renders during a single Astro build.
41
- const categoryMetaCache = new Map<string, Map<string, CategoryMeta>>();
42
- const navTreeCache = new Map<string, NavNode[]>();
60
+ function navTreeCacheSet(key: string, value: NavNode[]): void {
61
+ navTreeCache.delete(key);
62
+ navTreeCache.set(key, value);
63
+ if (navTreeCache.size > NAV_TREE_CACHE_MAX) {
64
+ const oldest = navTreeCache.keys().next().value;
65
+ if (oldest !== undefined) navTreeCache.delete(oldest);
66
+ }
67
+ }
43
68
 
44
69
  // Identity fast-path cache. Keyed on the docs-array reference: when nav-source
45
70
  // loaders hand back the SAME stable array instance across the build's many
@@ -99,12 +124,13 @@ function navTreeCacheKey(
99
124
  }
100
125
 
101
126
  /**
102
- * Build a recursive navigation tree from a flat Astro content collection.
127
+ * Build a recursive navigation tree from a flat content collection.
103
128
  * Mirrors the filesystem: directories become category nodes, files become leaves.
104
129
  *
105
- * Astro 5 glob() strips /index from IDs:
106
- * getting-started/index.mdx ID "getting-started" (category index)
107
- * getting-started/intro.mdx ID "getting-started/intro" (child page)
130
+ * Since #2030 the tree construction itself is delegated to the shared
131
+ * framework builder (`buildSidebarTree` in @takazudo/zudo-doc/sidebar-tree);
132
+ * this function keeps the host-side concerns: the NavNode shape consumed by
133
+ * every host nav surface, the content-key cache, and the identity fast-path.
108
134
  */
109
135
  export function buildNavTree(
110
136
  docs: DocsEntry[],
@@ -123,62 +149,110 @@ export function buildNavTree(
123
149
  }
124
150
 
125
151
  const cacheKey = navTreeCacheKey(docs, lang, categoryMeta);
126
- const cached = navTreeCache.get(cacheKey);
152
+ const cached = navTreeCacheGet(cacheKey);
127
153
  if (cached) {
128
154
  rememberIdentity(docs, lang, categoryMeta, cached);
129
155
  return cached;
130
156
  }
131
157
 
132
- const root: BuildNode = {
133
- segment: "",
134
- fullPath: "",
135
- children: new Map(),
136
- };
137
-
138
- for (const doc of docs) {
139
- const slug = doc.data.slug ?? toRouteSlug(doc.id);
140
- const parts = slug.split("/");
141
-
142
- if (parts.length <= 1) {
143
- // Category index: Astro 5 stripped /index → single segment like "guides".
144
- // Key by the route slug (honors a custom frontmatter `slug`), not the raw
145
- // id otherwise the sidebar/breadcrumb link diverges from the built route.
146
- const segment = parts[0] || doc.id;
147
- if (!root.children.has(segment)) {
148
- root.children.set(segment, {
149
- segment,
150
- fullPath: segment,
151
- children: new Map(),
152
- });
153
- }
154
- root.children.get(segment)!.doc = doc;
155
- } else {
156
- // Multi-segment: walk the tree creating intermediate nodes as needed
157
- let current = root;
158
- for (let i = 0; i < parts.length; i++) {
159
- const segment = parts[i] ?? "";
160
- const fullPath = parts.slice(0, i + 1).join("/");
161
- if (!current.children.has(segment)) {
162
- current.children.set(segment, {
163
- segment,
164
- fullPath,
165
- children: new Map(),
166
- });
167
- }
168
- if (i === parts.length - 1) {
169
- current.children.get(segment)!.doc = doc;
170
- }
171
- current = current.children.get(segment)!;
172
- }
173
- }
158
+ const sidebarTree = buildSidebarTree(
159
+ // Pass `{ id, data }` only — NOT the whole entry. zfb entries carry the
160
+ // raw, un-index-stripped engine slug on the top-level `slug` field
161
+ // (e.g. "getting-started/index"), and the shared builder prefers
162
+ // `entry.slug` over the id-derived form; forwarding it would mint wrong
163
+ // node paths. Omitting it reproduces the legacy host derivation
164
+ // `data.slug ?? toRouteSlug(id)` (ids arrive pre-stripped via _data.ts).
165
+ docs.map((d) => ({ id: d.id, data: d.data })),
166
+ lang,
167
+ {
168
+ categoryMeta,
169
+ buildHref: (slug, locale) => docsUrl(slug, locale),
170
+ // Host call sites own visibility: nav surfaces pre-filter via
171
+ // `stableNavDocs(docs.filter(isNavVisible))`, while the breadcrumb tree
172
+ // intentionally builds from the UNFILTERED list so unlisted pages still
173
+ // get breadcrumbs. Disable the builder's default unlisted/standalone
174
+ // filter so neither path changes behavior.
175
+ isNavVisible: () => true,
176
+ },
177
+ );
178
+ const result = sidebarTree.map(toNavNode);
179
+
180
+ // Root docs-index entry (derived slug "" — a root index.mdx arrives from
181
+ // _data.ts bridging as id ""). The shared builder drops empty slugs, but the
182
+ // legacy host builder minted a top-level node keyed "" (href /docs/) so the
183
+ // root page stayed present in sidebar/breadcrumb/prev-next data. Re-create
184
+ // that node here with the exact legacy field derivation, then re-sort with
185
+ // the same comparator the builder used (stable sort → idempotent for the
186
+ // already-sorted rest).
187
+ const rootDoc = findRootIndexDoc(docs);
188
+ if (rootDoc) {
189
+ result.push(toRootNavNode(rootDoc, lang, categoryMeta));
190
+ result.sort((a, b) => {
191
+ const posCompare = a.position - b.position;
192
+ if (posCompare !== 0) return posCompare;
193
+ return a.slug.localeCompare(b.slug);
194
+ });
174
195
  }
175
196
 
176
- const result = toNavNodes(root, lang, categoryMeta);
177
- navTreeCache.set(cacheKey, result);
197
+ navTreeCacheSet(cacheKey, result);
178
198
  rememberIdentity(docs, lang, categoryMeta, result);
179
199
  return result;
180
200
  }
181
201
 
202
+ /** Last entry whose package-derived slug is empty ("") — i.e. the entry the
203
+ * shared builder skips. Last one wins, mirroring the legacy builder's
204
+ * `node.doc = doc` overwrite. (A bare id "index" is NOT matched here: both
205
+ * the legacy and shared builders resolve it to a node keyed "index".) */
206
+ function findRootIndexDoc(docs: DocsEntry[]): DocsEntry | undefined {
207
+ let found: DocsEntry | undefined;
208
+ for (const d of docs) {
209
+ const slug = d.data.slug ?? d.id.replace(/\/index$/, "");
210
+ if (slug === "") found = d;
211
+ }
212
+ return found;
213
+ }
214
+
215
+ /** Legacy-faithful node for the root docs index (slug ""): no children are
216
+ * possible (a multi-segment slug never has an empty first part), label falls
217
+ * back through the same chain (title is required, so it always resolves),
218
+ * and href is the locale docs root. */
219
+ function toRootNavNode(
220
+ doc: DocsEntry,
221
+ lang: Locale,
222
+ categoryMeta?: Map<string, CategoryMeta>,
223
+ ): NavNode {
224
+ const meta = categoryMeta?.get("");
225
+ const noPage = doc.data.category_no_page ?? meta?.noPage;
226
+ const sortOrder = doc.data.category_sort_order ?? meta?.sortOrder ?? "asc";
227
+ return {
228
+ slug: "",
229
+ label: doc.data.sidebar_label ?? doc.data.title ?? meta?.label ?? "",
230
+ description: doc.data.description ?? meta?.description,
231
+ position: doc.data.sidebar_position ?? meta?.position ?? 999,
232
+ href: noPage ? undefined : docsUrl("", lang),
233
+ hasPage: noPage !== true,
234
+ children: [],
235
+ sortOrder,
236
+ };
237
+ }
238
+
239
+ /** Map the shared builder's SidebarNode shape onto the host NavNode shape.
240
+ * `position` and `sortOrder` defaults mirror the legacy inline builder:
241
+ * position falls back to 999 (ties sort alphabetically) and sortOrder is
242
+ * always materialized ("asc" unless frontmatter/sidecar set it). */
243
+ function toNavNode(node: SidebarNode): NavNode {
244
+ return {
245
+ slug: node.id,
246
+ label: node.label,
247
+ description: node.description,
248
+ position: node.sidebar_position ?? 999,
249
+ href: node.href,
250
+ hasPage: node.hasPage,
251
+ children: node.children.map(toNavNode),
252
+ sortOrder: node.sortOrder ?? "asc",
253
+ };
254
+ }
255
+
182
256
  /** Record a (docs-array identity, lang, categoryMeta) → tree mapping for the
183
257
  * identity fast-path. No-op-safe to call multiple times for the same slot. */
184
258
  function rememberIdentity(
@@ -197,55 +271,6 @@ function rememberIdentity(
197
271
  }
198
272
  }
199
273
 
200
- function toNavNodes(
201
- parent: BuildNode,
202
- lang: Locale,
203
- categoryMeta?: Map<string, CategoryMeta>,
204
- parentSortOrder?: "asc" | "desc",
205
- ): NavNode[] {
206
- const nodes: NavNode[] = [];
207
-
208
- for (const child of parent.children.values()) {
209
- const doc = child.doc;
210
- const meta = categoryMeta?.get(child.fullPath);
211
- // Frontmatter wins over the `_category_.json` sidecar when both exist.
212
- const noPage = doc?.data.category_no_page ?? meta?.noPage;
213
- const sortOrder = doc?.data.category_sort_order ?? meta?.sortOrder ?? "asc";
214
- const children = toNavNodes(child, lang, categoryMeta, sortOrder);
215
-
216
- nodes.push({
217
- slug: child.fullPath,
218
- label:
219
- doc?.data.sidebar_label ?? doc?.data.title ?? meta?.label ?? toTitleCase(child.segment),
220
- description: doc?.data.description ?? meta?.description,
221
- position: doc?.data.sidebar_position ?? meta?.position ?? 999,
222
- href: noPage
223
- ? undefined
224
- : doc || children.length > 0
225
- ? docsUrl(child.fullPath, lang)
226
- : undefined,
227
- // A `category_no_page` index.mdx is metadata-only — force hasPage false so
228
- // it matches a `_category_.json` noPage category (no backing file): a
229
- // non-linked header that flattenTree drops from prev/next. Otherwise the
230
- // route-excluded slug would surface as a 404 pagination target.
231
- hasPage: !!doc && noPage !== true,
232
- children,
233
- sortOrder,
234
- });
235
- }
236
-
237
- // Use the PARENT's sortOrder to sort these sibling nodes
238
- const order = parentSortOrder ?? "asc";
239
- nodes.sort((a, b) => {
240
- const posCompare = a.position - b.position;
241
- if (posCompare !== 0) return order === "desc" ? -posCompare : posCompare;
242
- const slugCompare = a.slug.localeCompare(b.slug);
243
- return order === "desc" ? -slugCompare : slugCompare;
244
- });
245
-
246
- return nodes;
247
- }
248
-
249
274
  /**
250
275
  * Group "satellite" nodes under their primary node based on slug prefixes.
251
276
  * E.g. with prefix "claude", nodes "claude-md", "claude-commands" get moved
@@ -348,8 +373,9 @@ export interface BreadcrumbItem {
348
373
  /**
349
374
  * Build breadcrumb trail by walking the nav tree.
350
375
  *
351
- * Nav-node hrefs are always the LATEST `docsUrl(slug, lang)` values (see
352
- * `toNavNodes`). On versioned routes that would make breadcrumbs link back to
376
+ * Nav-node hrefs are always the LATEST `docsUrl(slug, lang)` values (see the
377
+ * `buildHref` wiring in `buildNavTree`). On versioned routes that would make
378
+ * breadcrumbs link back to
353
379
  * latest content (#1916 #1). Pass an optional `hrefFor(slug)` to remap each
354
380
  * intermediate crumb's href to the route's own URL space (e.g.
355
381
  * `versionedDocsUrl`-bound). The home crumb and the current/last crumb carry no
@@ -387,54 +413,3 @@ export function buildBreadcrumbs(
387
413
 
388
414
  return crumbs;
389
415
  }
390
-
391
- /**
392
- * Scan a content directory for _category_.json files and return a map
393
- * of relative paths to category metadata.
394
- * Results are memoized by contentDir.
395
- */
396
- export function loadCategoryMeta(contentDir: string): Map<string, CategoryMeta> {
397
- const cached = categoryMetaCache.get(contentDir);
398
- if (cached) return cached;
399
- const result = new Map<string, CategoryMeta>();
400
- scanDir(contentDir, contentDir, result);
401
- categoryMetaCache.set(contentDir, result);
402
- return result;
403
- }
404
-
405
- function scanDir(baseDir: string, currentDir: string, result: Map<string, CategoryMeta>): void {
406
- let entries: fs.Dirent[];
407
- try {
408
- entries = fs.readdirSync(currentDir, { withFileTypes: true });
409
- } catch {
410
- return;
411
- }
412
-
413
- for (const entry of entries) {
414
- if (entry.isDirectory()) {
415
- const fullPath = path.join(currentDir, entry.name);
416
- const categoryFile = path.join(fullPath, "_category_.json");
417
- if (fs.existsSync(categoryFile)) {
418
- try {
419
- const raw = fs.readFileSync(categoryFile, "utf-8");
420
- const parsed: unknown = JSON.parse(raw);
421
- if (typeof parsed === "object" && parsed !== null) {
422
- const obj = parsed as Record<string, unknown>;
423
- const meta: CategoryMeta = {
424
- label: typeof obj.label === "string" ? obj.label : undefined,
425
- position: typeof obj.position === "number" ? obj.position : undefined,
426
- description: typeof obj.description === "string" ? obj.description : undefined,
427
- sortOrder: obj.sortOrder === "asc" || obj.sortOrder === "desc" ? obj.sortOrder : undefined,
428
- noPage: obj.noPage === true ? true : undefined,
429
- };
430
- const relativePath = path.relative(baseDir, fullPath);
431
- result.set(relativePath, meta);
432
- }
433
- } catch {
434
- // skip invalid JSON
435
- }
436
- }
437
- scanDir(baseDir, fullPath, result);
438
- }
439
- }
440
- }
@@ -1,135 +1,45 @@
1
+ // @ts-check
1
2
  // zfb plugin module: claude-resources.
2
3
  //
3
4
  // Wires `runClaudeResourcesPreStep` (from
4
5
  // `@takazudo/zudo-doc/integrations/claude-resources`) into zfb's
5
- // `preBuild` lifecycle hook. Replaces the npm `prebuild`-script glue
6
- // in `scripts/zfb-prebuild.mjs` for this step (the script remains in
7
- // place during the merge window — T6 retires it).
6
+ // `preBuild` lifecycle hook.
8
7
  //
9
- // Why this shim exists (and is not the v2 integration module directly):
10
- //
11
- // 1. zfb's plugin host runs `node` without any TypeScript loader, so
12
- // it cannot import `.ts` source files. The v2 integration package
13
- // (`@takazudo/zudo-doc/integrations/claude-resources`) currently
14
- // ships only TypeScript source through its `exports` map and has
15
- // no build step.
16
- //
17
- // 2. The `runClaudeResourcesPreStep` runner pulls in `gray-matter`,
18
- // which performs a CJS `require("fs")` that esbuild's
19
- // configuration-loader bundle (ESM-only) cannot satisfy. Loading
20
- // it inline at the top of `zfb.config.ts` therefore breaks config
21
- // parsing entirely.
22
- //
23
- // Both problems are solved by isolating the runner behind a child
24
- // process: this shim spawns `tsx` (the project's existing TS-aware
25
- // Node runner, pinned via `tsx` in `package.json`) on a tiny inline
26
- // script that imports the runner. tsx handles `.ts` resolution and
27
- // Node's CJS↔ESM interop for gray-matter; the parent plugin host stays
28
- // in plain Node and only sees a child-process exit code.
29
- //
30
- // Inline functions are not supported by zfb's plugin runtime — see
31
- // `@takazudo/zfb/plugins` source comment. This file is the plugin-host
32
- // equivalent once the npm script is retired (T6).
33
-
34
- import { spawn } from "node:child_process";
35
- import { fileURLToPath } from "node:url";
36
- import { dirname, resolve } from "node:path";
37
-
38
- const PLUGIN_NAME = "@takazudo/zudo-doc-claude-resources";
8
+ // Previously this shim spawned a `tsx` subprocess because the integration
9
+ // package shipped only TypeScript source (no build step) and `gray-matter`
10
+ // pulled in a CJS `require("fs")` that esbuild's ESM-only config-loader
11
+ // bundle could not satisfy. Both constraints are now lifted: the package
12
+ // ships compiled `dist/` and the plugin host is plain Node (not an esbuild
13
+ // bundle), so the runner can be imported directly.
39
14
 
40
- // `tsx` is a workspace dependency of the host project and resolves to
41
- // `<projectRoot>/node_modules/.bin/tsx` from this file. We resolve the
42
- // binary explicitly so the shim does not depend on the parent shell's
43
- // `PATH` (the plugin host is spawned by zfb without sourcing the
44
- // user's profile — Node's `PATH` is whatever zfb itself was launched
45
- // with).
46
- const HERE = dirname(fileURLToPath(import.meta.url));
47
- const TSX_BIN = resolve(HERE, "..", "node_modules", ".bin", "tsx");
15
+ /** @import { ZfbBuildHookContext, ZfbPlugin } from "@takazudo/zfb/plugins" */
48
16
 
49
- /**
50
- * Run the v2 claude-resources runner under tsx.
51
- *
52
- * Inlines a minimal ESM script via `tsx -e` so we don't have to ship a
53
- * second `.ts` file just to host the call site. The runner returns a
54
- * `{ claudemd, commands, skills, agents }` summary which we re-emit on
55
- * the child's stdout — the parent (this shim) parses it and forwards
56
- * the summary to the plugin host's logger.
57
- */
58
- function runRunnerUnderTsx({ claudeDir, projectRoot, docsDir }) {
59
- // The runner accepts `projectRoot` and `docsDir` as relative paths
60
- // (resolved against `process.cwd()` in the runner). To insulate the
61
- // child from whatever cwd zfb spawned us with, we set cwd explicitly
62
- // and pass absolute paths through.
63
- // tsx's `-e` flag emits CJS by default (it picks the format from the
64
- // entry's extension, and an inline `-e` script has none), so we wrap
65
- // the body in an `async`-IIFE rather than relying on top-level await.
66
- const childScript = `
67
- (async () => {
68
- const { runClaudeResourcesPreStep } = await import("@takazudo/zudo-doc/integrations/claude-resources");
69
- const result = await runClaudeResourcesPreStep({
70
- claudeDir: ${JSON.stringify(claudeDir)},
71
- projectRoot: ${JSON.stringify(projectRoot)},
72
- docsDir: ${JSON.stringify(docsDir)},
73
- });
74
- process.stdout.write(JSON.stringify(result));
75
- })().catch((err) => {
76
- process.stderr.write(err && err.stack ? err.stack : String(err));
77
- process.exit(1);
78
- });
79
- `;
17
+ import { runClaudeResourcesPreStep } from "@takazudo/zudo-doc/integrations/claude-resources";
80
18
 
81
- return new Promise((resolveCall, reject) => {
82
- const child = spawn(TSX_BIN, ["-e", childScript], {
83
- cwd: projectRoot,
84
- stdio: ["ignore", "pipe", "pipe"],
85
- });
86
- let stdout = "";
87
- let stderr = "";
88
- child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
89
- child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
90
- child.on("error", reject);
91
- child.on("close", (code) => {
92
- if (code !== 0) {
93
- reject(
94
- new Error(
95
- `[${PLUGIN_NAME}] runner exited with code ${code}\n${stderr}`,
96
- ),
97
- );
98
- return;
99
- }
100
- try {
101
- resolveCall(JSON.parse(stdout));
102
- } catch (err) {
103
- reject(
104
- new Error(
105
- `[${PLUGIN_NAME}] failed to parse runner stdout: ${err.message}\nstdout: ${stdout}\nstderr: ${stderr}`,
106
- ),
107
- );
108
- }
109
- });
110
- });
111
- }
19
+ const PLUGIN_NAME = "@takazudo/zudo-doc-claude-resources";
112
20
 
21
+ /** @type {ZfbPlugin} */
113
22
  export default {
114
23
  name: PLUGIN_NAME,
24
+
25
+ /** @param {ZfbBuildHookContext} ctx */
115
26
  async preBuild(ctx) {
116
- const claudeDir = ctx.options.claudeDir;
27
+ const claudeDir = ctx.options["claudeDir"];
117
28
  if (typeof claudeDir !== "string" || claudeDir.length === 0) {
118
29
  throw new Error(
119
30
  `[${PLUGIN_NAME}] preBuild: options.claudeDir must be a non-empty string (got ${JSON.stringify(claudeDir)})`,
120
31
  );
121
32
  }
122
- const projectRootOpt = ctx.options.projectRoot;
123
- const docsDirOpt = ctx.options.docsDir;
124
- const result = await runRunnerUnderTsx({
33
+ const projectRootOpt = ctx.options["projectRoot"];
34
+ const docsDirOpt = ctx.options["docsDir"];
35
+ const result = await runClaudeResourcesPreStep({
125
36
  claudeDir,
126
37
  projectRoot:
127
38
  typeof projectRootOpt === "string" ? projectRootOpt : ctx.projectRoot,
128
39
  docsDir: typeof docsDirOpt === "string" ? docsDirOpt : "src/content/docs",
129
40
  });
130
- // Surface a one-line summary so build logs make the migration
131
- // observable (mirrors the `[zfb-prebuild]` lines from the legacy
132
- // npm-script glue).
41
+ // Surface a one-line summary so build logs make the generation
42
+ // observable.
133
43
  ctx.logger.info(
134
44
  `claude-resources: ${result.claudemd} CLAUDE.md, ${result.commands} commands, ${result.skills} skills, ${result.agents} agents`,
135
45
  );