create-zudo-doc 0.2.0-next.9 → 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
package/dist/api.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import path from "path";
2
2
  import { scaffold } from "./scaffold.js";
3
- import { installDependencies } from "./utils.js";
3
+ import { installDependencies, validateProjectName } from "./utils.js";
4
4
  export async function createZudoDoc(options) {
5
5
  const { install = false, ...rest } = options;
6
+ const nameError = validateProjectName(rest.projectName);
7
+ if (nameError)
8
+ throw new Error(`Invalid projectName: ${nameError}`);
6
9
  const choices = { ...rest, defaultLang: rest.defaultLang ?? "en" };
7
10
  await scaffold(choices);
8
11
  const targetDir = path.resolve(process.cwd(), choices.projectName);
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import minimist from "minimist";
2
2
  import pc from "picocolors";
3
3
  import { FEATURES, SINGLE_SCHEMES, SUPPORTED_LANGS } from "./constants.js";
4
+ import { validateProjectName } from "./utils.js";
4
5
  export function parseArgs(argv = process.argv.slice(2)) {
5
6
  const raw = minimist(argv, {
6
7
  string: [
@@ -146,12 +147,9 @@ export function validateArgs(args) {
146
147
  return `--scheme is only valid with --color-scheme-mode single`;
147
148
  }
148
149
  if (args.name) {
149
- if (/^[./]|\.\./.test(args.name)) {
150
- return "Project name must not contain path traversal characters";
151
- }
152
- if (/[<>:"|?*\\]/.test(args.name)) {
153
- return "Project name contains invalid characters";
154
- }
150
+ const nameError = validateProjectName(args.name);
151
+ if (nameError)
152
+ return nameError;
155
153
  }
156
154
  return null;
157
155
  }
package/dist/preset.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import { FEATURES, SINGLE_SCHEMES, SUPPORTED_LANGS } from "./constants.js";
3
+ import { validateProjectName } from "./utils.js";
3
4
  const VALID_HEADER_RIGHT_COMPONENTS = new Set([
4
5
  "theme-toggle",
5
6
  "language-switcher",
@@ -34,6 +35,16 @@ export function validatePreset(json) {
34
35
  return "Preset must be a JSON object";
35
36
  }
36
37
  const p = json;
38
+ if (p.projectName !== undefined) {
39
+ // Untyped JSON — guard the type before the string validator, otherwise
40
+ // RegExp.test would coerce numbers/booleans and could accept them.
41
+ if (typeof p.projectName !== "string") {
42
+ return "Invalid projectName: must be a string";
43
+ }
44
+ const nameError = validateProjectName(p.projectName);
45
+ if (nameError)
46
+ return `Invalid projectName: ${nameError}`;
47
+ }
37
48
  if (p.features !== undefined && !Array.isArray(p.features)) {
38
49
  return `"features" must be an array in preset`;
39
50
  }
package/dist/prompts.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { LIGHT_DARK_PAIRINGS, SINGLE_SCHEMES, FEATURES, SUPPORTED_LANGS } from "./constants.js";
3
+ import { validateProjectName } from "./utils.js";
3
4
  export async function runPrompts(prefilled = {}) {
4
5
  // 1. Project name
5
6
  let projectName;
@@ -12,12 +13,7 @@ export async function runPrompts(prefilled = {}) {
12
13
  placeholder: "my-docs",
13
14
  defaultValue: "my-docs",
14
15
  validate(value) {
15
- if (!value.trim())
16
- return "Project name is required";
17
- if (/^[./]|\.\./.test(value))
18
- return "Project name must not contain path traversal characters";
19
- if (/[<>:"|?*\\]/.test(value))
20
- return "Project name contains invalid characters";
16
+ return validateProjectName(value) ?? undefined;
21
17
  },
22
18
  });
23
19
  if (p.isCancel(result))
package/dist/scaffold.js CHANGED
@@ -264,16 +264,21 @@ function generatePackageJson(choices) {
264
264
  // next.35 fixes resolve_links rewriting bare same-page `[text](#anchor)` /
265
265
  // `[text](?query)` links to `/<parent-dir>/#anchor` (zudolab/zudo-doc#1948,
266
266
  // upstream Takazudo/zudo-front-builder#875).
267
- "@takazudo/zfb": "0.1.0-next.35",
268
- "@takazudo/zfb-runtime": "0.1.0-next.35",
267
+ // next.38 adds client scripts (`.client.*` + `clientScript()`), the
268
+ // `when="media"` island strategy, exported VNode types, and stricter
269
+ // cross-file anchor validation. BREAKING upstream: the no-op
270
+ // `linkValidation.allowExternal` knob was removed — neither the host nor
271
+ // the generated config ever emitted it, so no migration is needed here.
272
+ "@takazudo/zfb": "0.1.0-next.38",
273
+ "@takazudo/zfb-runtime": "0.1.0-next.38",
269
274
  // zfb-adapter-cloudflare — required for any route with `prerender = false`.
270
275
  // Pinned in lockstep with @takazudo/zfb.
271
- "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.35",
276
+ "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.38",
272
277
  // @takazudo/zudo-doc — published from this monorepo via
273
278
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
274
279
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
275
280
  // version moves, so a fresh scaffold pulls the version we just published.
276
- "@takazudo/zudo-doc": "^0.2.0-next.9",
281
+ "@takazudo/zudo-doc": "^0.2.1",
277
282
  // zod — used by the generated zfb.config.ts. zfb-config-gen emits
278
283
  // `import { z } from "zod"` for the content-collection schema +
279
284
  // `z.toJSONSchema(...)` conversion. Without this dep, the consumer
@@ -297,11 +302,9 @@ function generatePackageJson(choices) {
297
302
  "@shikijs/transformers": "^4.0.0",
298
303
  clsx: "^2.1.0",
299
304
  "gray-matter": "^4.0.0",
300
- "github-slugger": "^2.0.0",
301
305
  mermaid: "^11.12.3",
302
306
  "remark-cjk-friendly": "^2.0.1",
303
307
  "remark-directive": "^3.0.0",
304
- "unist-util-visit": "^5.1.0",
305
308
  // katex — server-side LaTeX renderer used by the always-on
306
309
  // pages/lib/_math-block.tsx (called from pages/_mdx-components.ts
307
310
  // for `$…$` and `$$…$$` math nodes). Caught by W6B (#1735)
@@ -313,8 +316,6 @@ function generatePackageJson(choices) {
313
316
  "@tailwindcss/vite": "^4.2.0",
314
317
  tailwindcss: "^4.2.0",
315
318
  typescript: "^5.9.0",
316
- "@types/hast": "^3.0.4",
317
- "@types/mdast": "^4.0.4",
318
319
  "@types/node": "^22.0.0",
319
320
  "@types/react": "^19.2.0", // needed for preact/compat type resolution
320
321
  "html-validate": "^10.0.0",
@@ -331,7 +332,7 @@ function generatePackageJson(choices) {
331
332
  // @takazudo/zudo-doc/integrations/doc-history which in turn imports
332
333
  // @takazudo/zudo-doc-history-server/git-history. Without this dep the
333
334
  // plugin host fails at init with ERR_MODULE_NOT_FOUND — W8A (#1739).
334
- deps["@takazudo/zudo-doc-history-server"] = "^0.2.0-next.9";
335
+ deps["@takazudo/zudo-doc-history-server"] = "^0.2.1";
335
336
  // W7A (#1736): doc-history-plugin.mjs spawns `tsx -e <inline-script>` to
336
337
  // run the v2 runtime in a TS-aware Node subprocess; without tsx the
337
338
  // plugin's preBuild step exits with ENOENT before zfb finishes config
@@ -363,6 +364,11 @@ function generatePackageJson(choices) {
363
364
  build: "zfb build",
364
365
  preview: "zfb preview",
365
366
  check: "zfb check",
367
+ // NOTE: no `check:pages` here — the host repo's pages/ typecheck
368
+ // (tsconfig.pages.json, #2018) is host-only for now. The base template's
369
+ // no-op feature stubs (e.g. doc-history.tsx) are not type-clean against
370
+ // the pages/lib call sites, so emitting the script would fail on a fresh
371
+ // scaffold. Revisit once the template stubs carry typed props.
366
372
  "check:html": "html-validate \"dist/**/*.html\"",
367
373
  };
368
374
  if (choices.features.includes("tagGovernance")) {
@@ -43,7 +43,7 @@ export function generateSettingsFile(choices) {
43
43
  lines.push(` lightScheme: ${JSON.stringify(choices.lightScheme ?? "GitHub Light")},`);
44
44
  lines.push(` darkScheme: ${JSON.stringify(choices.darkScheme ?? "GitHub Dark")},`);
45
45
  lines.push(` respectPrefersColorScheme: ${choices.respectPrefersColorScheme ?? true},`);
46
- lines.push(` } satisfies ColorModeConfig,`);
46
+ lines.push(` } satisfies ColorModeConfig as ColorModeConfig | false,`);
47
47
  }
48
48
  lines.push(` siteName: ${JSON.stringify(capitalize(choices.projectName.replace(/-/g, " ")))},`);
49
49
  lines.push(` siteDescription: "" as string,`);
@@ -60,16 +60,16 @@ export function generateSettingsFile(choices) {
60
60
  }
61
61
  lines.push(` siteUrl: "" as string,`);
62
62
  lines.push(` docsDir: "src/content/docs",`);
63
- lines.push(` defaultLocale: ${JSON.stringify(choices.defaultLang ?? "en")} as string,`);
63
+ lines.push(` defaultLocale: ${JSON.stringify(choices.defaultLang ?? "en")} as const,`);
64
64
  if (choices.features.includes("i18n")) {
65
65
  const secondaryLang = getSecondaryLang(choices.defaultLang);
66
66
  const secondaryLabel = getLangLabel(secondaryLang);
67
67
  lines.push(` locales: {`);
68
68
  lines.push(` ${secondaryLang}: { label: ${JSON.stringify(secondaryLabel)}, dir: "src/content/docs-${secondaryLang}" },`);
69
- lines.push(` } as Record<string, LocaleConfig>,`);
69
+ lines.push(` } satisfies Record<string, LocaleConfig>,`);
70
70
  }
71
71
  else {
72
- lines.push(` locales: {} as Record<string, LocaleConfig>,`);
72
+ lines.push(` locales: {} satisfies Record<string, LocaleConfig>,`);
73
73
  }
74
74
  // mermaid is controlled by the markdown.features block in zfb.config.ts
75
75
  // (zfb next.12+). This field is retained for compatibility with framework
@@ -159,7 +159,7 @@ export function generateSettingsFile(choices) {
159
159
  }
160
160
  lines.push(` htmlPreview: undefined as HtmlPreviewConfig | undefined,`);
161
161
  if (choices.features.includes("versioning")) {
162
- lines.push(` versions: [] as VersionConfig[],`);
162
+ lines.push(` versions: [] satisfies VersionConfig[] as VersionConfig[] | false,`);
163
163
  }
164
164
  else {
165
165
  lines.push(` versions: false as VersionConfig[] | false,`);
@@ -219,7 +219,7 @@ export function generateSettingsFile(choices) {
219
219
  if (choices.features.includes("changelog")) {
220
220
  lines.push(` { label: "Changelog", path: "/docs/changelog", categoryMatch: "changelog" },`);
221
221
  }
222
- lines.push(` ] as HeaderNavItem[],`);
222
+ lines.push(` ] satisfies HeaderNavItem[] as HeaderNavItem[],`);
223
223
  lines.push(` headerRightItems: [`);
224
224
  if (choices.headerRightItems !== undefined) {
225
225
  // User-supplied override (including empty array): emit each entry verbatim,
@@ -250,7 +250,7 @@ export function generateSettingsFile(choices) {
250
250
  lines.push(` { type: "component", component: "language-switcher" },`);
251
251
  }
252
252
  }
253
- lines.push(` ] as HeaderRightItem[],`);
253
+ lines.push(` ] satisfies HeaderRightItem[] as HeaderRightItem[],`);
254
254
  lines.push(`};`);
255
255
  return lines.join("\n") + "\n";
256
256
  }
package/dist/utils.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Validate a project name against the locked grammar.
3
+ *
4
+ * Returns `null` when valid; a human-readable error string when invalid.
5
+ * Apply on every input path: CLI arg, interactive prompt, preset, and
6
+ * programmatic API.
7
+ */
8
+ export declare function validateProjectName(name: string): string | null;
1
9
  export declare function installDependencies(dir: string, pm: string): void;
2
10
  export declare function capitalize(str: string): string;
3
11
  /** Get a short uppercase label for a language code (e.g. "en" → "EN", "zh-cn" → "ZH-CN"). */
package/dist/utils.js CHANGED
@@ -1,5 +1,30 @@
1
1
  import { execSync } from "child_process";
2
2
  import fs from "fs-extra";
3
+ // Project-name grammar (locked by F4 — S4 #2013):
4
+ // /^[a-z0-9][a-z0-9._-]*$/, max 214 chars, unscoped, used as both directory
5
+ // name and package name. Mirrors npm's unscoped-name rules + max path safety.
6
+ const PROJECT_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
7
+ const PROJECT_NAME_MAX = 214;
8
+ /**
9
+ * Validate a project name against the locked grammar.
10
+ *
11
+ * Returns `null` when valid; a human-readable error string when invalid.
12
+ * Apply on every input path: CLI arg, interactive prompt, preset, and
13
+ * programmatic API.
14
+ */
15
+ export function validateProjectName(name) {
16
+ if (!name || name.length === 0) {
17
+ return "Project name is required";
18
+ }
19
+ if (name.length > PROJECT_NAME_MAX) {
20
+ return `Project name must be ${PROJECT_NAME_MAX} characters or fewer`;
21
+ }
22
+ if (!PROJECT_NAME_RE.test(name)) {
23
+ return ("Project name must start with a lowercase letter or digit and contain " +
24
+ "only lowercase letters, digits, dots, underscores, and hyphens");
25
+ }
26
+ return null;
27
+ }
3
28
  export function installDependencies(dir, pm) {
4
29
  const commands = {
5
30
  pnpm: "pnpm install",
@@ -19,62 +19,20 @@ export function generateZfbConfig(choices) {
19
19
  const hasDocHistory = choices.features.includes("docHistory");
20
20
  const hasLlmsTxt = choices.features.includes("llmsTxt");
21
21
  const hasClaudeResources = choices.features.includes("claudeResources");
22
- const hasTagGovernance = choices.features.includes("tagGovernance");
23
22
  const lines = [];
24
23
  // --- Imports ---
25
24
  lines.push(`import { z } from "zod";`);
26
25
  lines.push(`import { defineConfig } from "zfb/config";`);
27
26
  lines.push(`import { settings } from "./src/config/settings";`);
28
- if (hasTagGovernance) {
29
- lines.push(`import { tagVocabulary } from "./src/config/tag-vocabulary";`);
30
- }
27
+ lines.push(`import { buildDocsSchema } from "./src/config/docs-schema";`);
31
28
  lines.push(``);
32
- // --- Tags schema builder (only when tagGovernance is selected) ---
33
- if (hasTagGovernance) {
34
- lines.push(`function buildTagsSchema() {`);
35
- lines.push(` const vocabularyActive = settings.tagVocabulary && settings.tagGovernance === "strict";`);
36
- lines.push(` if (!vocabularyActive) return z.array(z.string()).optional();`);
37
- lines.push(` const allowed = new Set<string>();`);
38
- lines.push(` for (const entry of tagVocabulary) {`);
39
- lines.push(` allowed.add(entry.id);`);
40
- lines.push(` for (const alias of entry.aliases ?? []) allowed.add(alias);`);
41
- lines.push(` }`);
42
- lines.push(` const allowedList = [...allowed];`);
43
- lines.push(` if (allowedList.length === 0) return z.array(z.string()).optional();`);
44
- lines.push(` const [first, ...rest] = allowedList;`);
45
- lines.push(` return z.array(z.enum([first, ...rest] as [string, ...string[]])).optional();`);
46
- lines.push(`}`);
47
- lines.push(``);
48
- }
49
- // --- Schema definition ---
50
- lines.push(`const docsSchema = z`);
51
- lines.push(` .object({`);
52
- lines.push(` title: z.string(),`);
53
- lines.push(` description: z.string().optional(),`);
54
- lines.push(` category: z.string().optional(),`);
55
- lines.push(` sidebar_position: z.number().optional(),`);
56
- lines.push(` sidebar_label: z.string().optional(),`);
57
- if (hasTagGovernance) {
58
- lines.push(` tags: buildTagsSchema(),`);
59
- }
60
- else {
61
- lines.push(` tags: z.array(z.string()).optional(),`);
62
- }
63
- lines.push(` search_exclude: z.boolean().optional(),`);
64
- lines.push(` pagination_next: z.string().nullable().optional(),`);
65
- lines.push(` pagination_prev: z.string().nullable().optional(),`);
66
- lines.push(` draft: z.boolean().optional(),`);
67
- lines.push(` unlisted: z.boolean().optional(),`);
68
- lines.push(` hide_sidebar: z.boolean().optional(),`);
69
- lines.push(` hide_toc: z.boolean().optional(),`);
70
- lines.push(` doc_history: z.boolean().optional(),`);
71
- lines.push(` standalone: z.boolean().optional(),`);
72
- lines.push(` slug: z.string().optional(),`);
73
- lines.push(` generated: z.boolean().optional(),`);
74
- lines.push(` category_no_page: z.boolean().optional(),`);
75
- lines.push(` category_sort_order: z.enum(["asc", "desc"]).optional(),`);
76
- lines.push(` })`);
77
- lines.push(` .passthrough();`);
29
+ // --- Schema definition delegated to the single source of truth ---
30
+ // buildDocsSchema() lives in src/config/docs-schema.ts and is shared by
31
+ // pages/_data.ts (ZfbDocsData alias) and src/types/docs-entry.ts (DocsData).
32
+ // tagGovernance projects: docs-schema.ts reads settings + tagVocabulary
33
+ // internally, so the generated zfb.config.ts needs no extra import or
34
+ // inline buildTagsSchema the schema builder encapsulates all of that.
35
+ lines.push(`const docsSchema = buildDocsSchema();`);
78
36
  lines.push(``);
79
37
  lines.push(`const docsSchemaJson = z.toJSONSchema(docsSchema) as Record<string, unknown>;`);
80
38
  lines.push(``);
@@ -203,6 +161,9 @@ export function generateZfbConfig(choices) {
203
161
  // --- Export ---
204
162
  lines.push(`export default defineConfig({`);
205
163
  lines.push(` framework: "preact",`);
164
+ lines.push(` // Pin the dev/preview port — zfb defaults to 3000, but the generated`);
165
+ lines.push(` // CLAUDE.md and the Tauri dev wrappers assume 4321.`);
166
+ lines.push(` port: 4321,`);
206
167
  lines.push(` tailwind: { enabled: true },`);
207
168
  lines.push(` collections,`);
208
169
  lines.push(` stripMdExt: true,`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "0.2.0-next.9",
3
+ "version": "0.2.1",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { getCollection } from "zfb/content";
14
14
  import type { CollectionEntry } from "zfb/content";
15
+ import type { DocsData } from "@/config/docs-schema";
15
16
  import type { DocsEntry } from "@/types/docs-entry";
16
17
  import type { DocPageEntry } from "./lib/doc-page-props";
17
18
  import { toRouteSlug } from "@/utils/slug";
@@ -22,30 +23,16 @@ import { toRouteSlug } from "@/utils/slug";
22
23
 
23
24
  /**
24
25
  * Frontmatter shape shared by all docs collections (EN, locale, versioned).
25
- * Matches the zod schema in zfb.config.ts field-for-field.
26
- * `.passthrough()` equivalent: the index signature [key: string]: unknown
27
- * keeps custom frontmatter keys available (e.g. for frontmatter-preview).
26
+ *
27
+ * Re-exported alias for `DocsData` (the `z.infer`-derived type from
28
+ * `src/config/docs-schema.ts`) so call sites that import `ZfbDocsData` from
29
+ * `pages/_data` continue to work without changes.
30
+ *
31
+ * The `[key: string]: unknown` index signature comes from `.passthrough()` on
32
+ * the zod schema — custom frontmatter keys remain accessible downstream (e.g.
33
+ * for frontmatter-preview) without extra casting.
28
34
  */
29
- export type ZfbDocsData = {
30
- title: string;
31
- description?: string;
32
- category?: string;
33
- sidebar_position?: number;
34
- sidebar_label?: string;
35
- tags?: string[];
36
- search_exclude?: boolean;
37
- pagination_next?: string | null;
38
- pagination_prev?: string | null;
39
- draft?: boolean;
40
- unlisted?: boolean;
41
- hide_sidebar?: boolean;
42
- hide_toc?: boolean;
43
- doc_history?: boolean;
44
- standalone?: boolean;
45
- slug?: string;
46
- generated?: boolean;
47
- [key: string]: unknown;
48
- };
35
+ export type ZfbDocsData = DocsData;
49
36
 
50
37
  /**
51
38
  * zfb collection entry augmented with the `id` and `collection` fields that
@@ -21,33 +21,19 @@
21
21
  //
22
22
  // Locale: defaultLocale (EN). Non-default locales are handled by
23
23
  // pages/[locale]/docs/[[...slug]].tsx.
24
+ //
25
+ // Enumeration + per-entry derived data (breadcrumbs, prev/next, headings) are
26
+ // built by the shared, memoized buildDocRouteEntries (#2010); rendering by the
27
+ // shared renderDocPage. This file owns only the route's nav source and the
28
+ // param/prop shapes.
24
29
 
25
30
  import { settings } from "@/config/settings";
26
31
  import { defaultLocale } from "@/config/i18n";
27
- import { docsUrl, absoluteUrl } from "@/utils/base";
28
- import {
29
- buildNavTree,
30
- buildBreadcrumbs,
31
- collectAutoIndexNodes,
32
- type NavNode,
33
- } from "@/utils/docs";
34
- import { getNavSectionForSlug, getNavSubtree } from "@/utils/nav-scope";
35
- import { toRouteSlug, toSlugParams } from "@/utils/slug";
36
- // Shared MDX-tag → Preact-component bag. Includes htmlOverrides
37
- // (native typography), HtmlPreviewWrapper (Island), and stub bindings
38
- // for every other custom tag the MDX corpus references — see
39
- // `pages/_mdx-components.ts` for the full list and rationale.
40
- import { createMdxComponents } from "../_mdx-components";
41
- import { DocHistoryArea } from "../lib/_doc-history-area";
42
- import { DocMetainfoArea } from "../lib/_doc-metainfo-area";
43
- import { buildInlineVersionSwitcher } from "../lib/_inline-version-switcher";
44
32
  import type { JSX } from "preact";
45
33
  import { resolveNavSource } from "../lib/_nav-source-docs";
46
- import { extractHeadings } from "../lib/_extract-headings";
47
- import type { DocPageEntry, AutoIndexNode, DocPageEntryProps, DocPageAutoIndexProps } from "../lib/doc-page-props";
48
- import { DocContentHeader } from "../lib/_doc-content-header";
49
- import { DocPageShell } from "../lib/_doc-page-shell";
50
- import { resolveDocPrevNext, flattenSubtree } from "../lib/_doc-route-paths";
34
+ import type { DocPageEntryProps, DocPageAutoIndexProps } from "../lib/doc-page-props";
35
+ import { buildDocRouteEntries } from "../lib/_doc-route-entries";
36
+ import { renderDocPage } from "../lib/_doc-page-renderer";
51
37
 
52
38
  export const frontmatter = { title: "Docs" };
53
39
 
@@ -55,8 +41,6 @@ export const frontmatter = { title: "Docs" };
55
41
  // Props contract
56
42
  // ---------------------------------------------------------------------------
57
43
 
58
- // DocPageEntry, AutoIndexNode imported from pages/lib/doc-page-props.ts
59
-
60
44
  type DocPageProps = DocPageEntryProps | DocPageAutoIndexProps;
61
45
 
62
46
  // ---------------------------------------------------------------------------
@@ -67,8 +51,8 @@ type DocPageProps = DocPageEntryProps | DocPageAutoIndexProps;
67
51
  * Enumerate all doc routes for the default locale (EN).
68
52
  *
69
53
  * Synchronous per ADR-004: getCollection() resolves from the pre-loaded
70
- * ContentSnapshot. All nav-tree and breadcrumb computation is done here
71
- * so the component is a pure renderer.
54
+ * ContentSnapshot. All nav-tree and breadcrumb computation is done in the
55
+ * shared builder so the component is a pure renderer.
72
56
  */
73
57
  export function paths(): Array<{
74
58
  params: { slug: string[] };
@@ -77,66 +61,19 @@ export function paths(): Array<{
77
61
  const locale = defaultLocale;
78
62
  // Identity-stable nav source (draft-filtered, unlisted retained). The same
79
63
  // instances are returned across this route's many per-page paths()
80
- // invocations, so buildNavTree's identity fast-path skips the key
81
- // recomputation — see pages/lib/_nav-source-docs.ts (#1902).
82
- const { docs, navDocs, categoryMeta } = resolveNavSource(locale, undefined);
83
-
84
- // Nav docs: exclude unlisted (for sidebar/prev-next) but keep for breadcrumbs
85
- const tree = buildNavTree(navDocs, locale, categoryMeta);
86
- // Full tree (including unlisted) for accurate breadcrumbs
87
- const fullTree = buildNavTree(docs, locale, categoryMeta);
88
-
89
- const result: Array<{ params: { slug: string[] }; props: DocPageProps }> = [];
90
-
91
- // Regular doc pages
92
- for (const entry of docs) {
93
- // A `category_no_page` index.mdx carries category metadata only — keep it
94
- // in the nav tree (built above, used for breadcrumbs) but emit NO route for
95
- // it. zfb's walker retains every .mdx as a collection entry, so without
96
- // this explicit skip the metadata file would silently add a route.
97
- if (entry.data.category_no_page === true) continue;
98
- const slug = entry.data.slug ?? toRouteSlug(entry.slug);
99
- const navSection = getNavSectionForSlug(slug);
100
- const subtree = getNavSubtree(tree, navSection);
101
-
102
- // Prev/next + frontmatter pagination overrides resolved against THIS
103
- // route's own `tree`. Latest route — hrefs stay unversioned (no rewrite).
104
- const { prev: prevNode, next: nextNode } = resolveDocPrevNext(
105
- tree,
106
- flattenSubtree(subtree),
107
- slug,
108
- entry.data,
109
- );
110
-
111
- result.push({
112
- params: { slug: toSlugParams(slug) },
113
- props: {
114
- kind: "entry",
115
- entry,
116
- breadcrumbs: buildBreadcrumbs(fullTree, slug, locale),
117
- prev: prevNode,
118
- next: nextNode,
119
- headings: extractHeadings(entry.body ?? ""),
120
- },
121
- });
122
- }
123
-
124
- // Auto-generated index pages for categories without index.mdx
125
- for (const node of collectAutoIndexNodes(tree)) {
126
- result.push({
127
- params: { slug: toSlugParams(node.slug) },
128
- props: {
129
- kind: "autoIndex",
130
- autoIndex: node as AutoIndexNode,
131
- breadcrumbs: buildBreadcrumbs(fullTree, node.slug, locale),
132
- prev: null,
133
- next: null,
134
- headings: [],
135
- },
136
- });
137
- }
138
-
139
- return result;
64
+ // invocations, so both buildNavTree's identity fast-path and the
65
+ // buildDocRouteEntries memo key on them — see pages/lib/_nav-source-docs.ts
66
+ // (#1902).
67
+ const source = resolveNavSource(locale, undefined);
68
+
69
+ return buildDocRouteEntries({
70
+ source,
71
+ locale,
72
+ routeSig: `docs;${locale}`,
73
+ }).map((item) => ({
74
+ params: { slug: item.slugParams },
75
+ props: item.props,
76
+ }));
140
77
  }
141
78
 
142
79
  // ---------------------------------------------------------------------------
@@ -146,86 +83,8 @@ export function paths(): Array<{
146
83
  type PageArgs = DocPageProps & { params: { slug: string[] } };
147
84
 
148
85
  export default function DocsPage(props: PageArgs): JSX.Element {
149
- const { breadcrumbs, prev, next, headings } = props;
150
- const locale = defaultLocale;
151
-
152
- const slug = props.kind === "autoIndex"
153
- ? props.autoIndex.slug
154
- : (props.entry.data.slug ?? toRouteSlug(props.entry.slug));
155
-
156
- const title = props.kind === "autoIndex" ? props.autoIndex.label : props.entry.data.title;
157
- const description = props.kind === "autoIndex" ? props.autoIndex.description : props.entry.data.description;
158
-
159
- // Locale-aware components bag — creates nav wrappers bound to the active
160
- // locale so CategoryNav/CategoryTreeNav/SiteTreeNav query the right collection.
161
- const components = createMdxComponents(locale);
162
-
163
- // Resolve child hrefs for auto-index pages — latest route keeps the nav
164
- // node's own docsUrl href (fallback for a noPage parent without an href).
165
- const autoIndexChildren = props.kind === "autoIndex"
166
- ? props.autoIndex.children
167
- .filter((c: NavNode) => c.hasPage || c.children.length > 0)
168
- .map((c: NavNode) => ({
169
- ...c,
170
- href: c.href ?? docsUrl(c.slug, locale),
171
- }))
172
- : [];
173
-
174
- // Canonical URL — base-prefixed page path, absolutized against siteUrl.
175
- const currentPath = docsUrl(slug, locale);
176
- const canonical = absoluteUrl(currentPath);
177
-
178
- // Persist key: locale + nav-section so the sidebar DOM node is reused
179
- // across same-locale + same-section navigations only. No sanitizer needed —
180
- // both lang (BCP-47 locale string) and navSection (filesystem-derived
181
- // kebab-case slug) come from controlled, trusted sources.
182
- const navSection = getNavSectionForSlug(slug);
183
- const hideSidebar = props.kind === "entry" ? props.entry.data.hide_sidebar : undefined;
184
- const sidebarPersistKey = hideSidebar
185
- ? undefined
186
- : `sidebar-${locale}-${navSection ?? "default"}`;
187
-
188
- return (
189
- <DocPageShell
190
- kind={props.kind}
191
- locale={locale}
192
- slug={slug}
193
- title={title}
194
- description={description}
195
- canonical={canonical}
196
- breadcrumbs={breadcrumbs}
197
- prev={prev}
198
- next={next}
199
- headings={headings}
200
- navSection={navSection}
201
- sidebarPersistKey={sidebarPersistKey}
202
- hideSidebar={hideSidebar}
203
- hideToc={props.kind === "entry" ? props.entry.data.hide_toc : undefined}
204
- currentPath={currentPath}
205
- versionSwitcher={buildInlineVersionSwitcher(slug, locale)}
206
- autoIndexLabel={props.kind === "autoIndex" ? props.autoIndex.label : undefined}
207
- autoIndexChildren={autoIndexChildren}
208
- metainfoSlot={
209
- props.kind === "autoIndex" ? <DocMetainfoArea slug={slug} locale={locale} /> : null
210
- }
211
- contentHeaderSlot={
212
- props.kind === "entry" ? (
213
- <DocContentHeader entry={props.entry} slug={slug} locale={locale} />
214
- ) : undefined
215
- }
216
- contentSlot={
217
- props.kind === "entry" ? <props.entry.Content components={components} /> : undefined
218
- }
219
- docHistorySlot={
220
- props.kind === "entry" && !props.entry.data.unlisted ? (
221
- <DocHistoryArea
222
- slug={slug}
223
- locale={locale}
224
- entrySlug={props.entry.slug}
225
- contentDir={settings.docsDir}
226
- />
227
- ) : null
228
- }
229
- />
230
- );
86
+ return renderDocPage(props, {
87
+ locale: defaultLocale,
88
+ docHistoryContentDir: settings.docsDir,
89
+ });
231
90
  }