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
@@ -64,7 +64,9 @@ function parseFrontmatter(content: string) {
64
64
  }
65
65
 
66
66
  function escapeTitle(s: string): string {
67
- return s.replace(/"/g, '\\"');
67
+ // Backslashes must be escaped first — the value is embedded in
68
+ // double-quoted YAML frontmatter where `\d` or `C:\path` is invalid.
69
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
68
70
  }
69
71
 
70
72
  function listFiles(dir: string): string[] {
@@ -93,6 +95,35 @@ generated: true
93
95
  fs.writeFileSync(path.join(outputDir, "index.mdx"), mdx);
94
96
  }
95
97
 
98
+ /**
99
+ * Writes an unlisted sub-page MDX file (flat file with a custom nested slug).
100
+ * Used for skill references, scripts, and assets.
101
+ */
102
+ function writeUnlistedSubPage(
103
+ outputPath: string,
104
+ title: string,
105
+ slug: string,
106
+ body: string,
107
+ ) {
108
+ fs.writeFileSync(
109
+ outputPath,
110
+ `---\ntitle: "${escapeTitle(title)}"\nslug: "${slug}"\nunlisted: true\ngenerated: true\n---\n\n${body}\n`,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Guards that the given name/slug is not the reserved "index" value.
116
+ * Throws with a contextual message if it is.
117
+ */
118
+ function assertNotIndexReserved(
119
+ nameOrSlug: string,
120
+ errorMessage: string,
121
+ ) {
122
+ if (nameOrSlug === "index") {
123
+ throw new Error(errorMessage);
124
+ }
125
+ }
126
+
96
127
  // ---------------------------------------------------------------------------
97
128
  // CLAUDE.md discovery
98
129
  // ---------------------------------------------------------------------------
@@ -176,11 +207,10 @@ function generateClaudemdDocs(
176
207
 
177
208
  const emittedSlugs = new Map<string, string>();
178
209
  items.forEach((item, index) => {
179
- if (item.slug === "index") {
180
- throw new Error(
181
- `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.`,
182
- );
183
- }
210
+ assertNotIndexReserved(
211
+ item.slug,
212
+ `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.`,
213
+ );
184
214
  const previous = emittedSlugs.get(item.slug);
185
215
  if (previous !== undefined) {
186
216
  throw new Error(
@@ -232,11 +262,10 @@ function generateCommandsDocs(config: ClaudeResourcesConfig): CommandItem[] {
232
262
  if (!parsed) continue;
233
263
 
234
264
  const name = file.replace(/\.md$/, "");
235
- if (name === "index") {
236
- throw new Error(
237
- `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.`,
238
- );
239
- }
265
+ assertNotIndexReserved(
266
+ name,
267
+ `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.`,
268
+ );
240
269
  const description = (parsed.data.description as string) || "";
241
270
 
242
271
  items.push({ name, description });
@@ -357,11 +386,10 @@ function generateSkillsDocs(config: ClaudeResourcesConfig): SkillItem[] {
357
386
  const items: SkillItem[] = [];
358
387
 
359
388
  for (const dir of dirs) {
360
- if (dir === "index") {
361
- throw new Error(
362
- `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.`,
363
- );
364
- }
389
+ assertNotIndexReserved(
390
+ dir,
391
+ `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.`,
392
+ );
365
393
  const content = fs.readFileSync(
366
394
  path.join(skillsDir, dir, "SKILL.md"),
367
395
  "utf8",
@@ -445,46 +473,43 @@ ${body}`;
445
473
  const skillSlugBase = `claude-skills/${dir}`;
446
474
 
447
475
  for (const ref of references) {
448
- const subSlug = `${skillSlugBase}/ref-${ref.name}`;
449
- const refMdx = `---
450
- title: "${escapeTitle(ref.title)}"
451
- slug: "${subSlug}"
452
- unlisted: true
453
- generated: true
454
- ---
455
-
456
- ${escapeForMdx(ref.content.trim())}
457
- `;
458
- fs.writeFileSync(path.join(outputDir, `${dir}--ref-${ref.name}.mdx`), refMdx);
476
+ writeUnlistedSubPage(
477
+ path.join(outputDir, `${dir}--ref-${ref.name}.mdx`),
478
+ ref.title,
479
+ `${skillSlugBase}/ref-${ref.name}`,
480
+ escapeForMdx(ref.content.trim()),
481
+ );
459
482
  }
460
483
 
461
484
  for (const f of scriptFiles.filter((s) => s.endsWith(".md"))) {
462
485
  const slug = f.replace(/\.md$/, "");
463
- const subSlug = `${skillSlugBase}/script-${slug}`;
464
486
  const raw = fs.readFileSync(
465
487
  path.join(skillsDir, dir, "scripts", f),
466
488
  "utf8",
467
489
  );
468
490
  const h1Match = raw.match(/^#\s+(.+)$/m);
469
491
  const title = h1Match ? h1Match[1] : slug;
470
- fs.writeFileSync(
492
+ writeUnlistedSubPage(
471
493
  path.join(outputDir, `${dir}--script-${slug}.mdx`),
472
- `---\ntitle: "${escapeTitle(title)}"\nslug: "${subSlug}"\nunlisted: true\ngenerated: true\n---\n\n${escapeForMdx(raw.trim())}\n`,
494
+ title,
495
+ `${skillSlugBase}/script-${slug}`,
496
+ escapeForMdx(raw.trim()),
473
497
  );
474
498
  }
475
499
 
476
500
  for (const f of assetFiles.filter((a) => a.endsWith(".md"))) {
477
501
  const slug = f.replace(/\.md$/, "");
478
- const subSlug = `${skillSlugBase}/asset-${slug}`;
479
502
  const raw = fs.readFileSync(
480
503
  path.join(skillsDir, dir, "assets", f),
481
504
  "utf8",
482
505
  );
483
506
  const h1Match = raw.match(/^#\s+(.+)$/m);
484
507
  const title = h1Match ? h1Match[1] : slug;
485
- fs.writeFileSync(
508
+ writeUnlistedSubPage(
486
509
  path.join(outputDir, `${dir}--asset-${slug}.mdx`),
487
- `---\ntitle: "${escapeTitle(title)}"\nslug: "${subSlug}"\nunlisted: true\ngenerated: true\n---\n\n${escapeForMdx(raw.trim())}\n`,
510
+ title,
511
+ `${skillSlugBase}/asset-${slug}`,
512
+ escapeForMdx(raw.trim()),
488
513
  );
489
514
  }
490
515
  }
@@ -522,11 +547,10 @@ function generateAgentsDocs(config: ClaudeResourcesConfig): AgentItem[] {
522
547
  const description = (parsed.data.description as string) || "";
523
548
  const model = (parsed.data.model as string) || "";
524
549
  const fileSlug = file.replace(/\.md$/, "");
525
- if (fileSlug === "index") {
526
- throw new Error(
527
- `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.`,
528
- );
529
- }
550
+ assertNotIndexReserved(
551
+ fileSlug,
552
+ `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.`,
553
+ );
530
554
 
531
555
  items.push({ name, file: fileSlug, description, model });
532
556
 
@@ -6,11 +6,12 @@
6
6
  *
7
7
  * Type notes:
8
8
  * - zdtp's `ColorScheme` requires a `shikiTheme: string` field that is not
9
- * present in zudo-doc's local `ColorScheme` type or data. The cast below
10
- * (`as unknown as Record<string, ZdtpColorScheme>`) is intentional: zdtp
11
- * uses `shikiTheme` only for the code-block preview inside the panel; when
12
- * absent at runtime it falls back to `colorExtras.defaultShikiTheme`. No
13
- * user-visible regression results from the missing field.
9
+ * present in this project's local `ColorScheme` type or data (zdtp uses it
10
+ * only for the panel's client-side code-block preview). Rather than an unsafe
11
+ * `as unknown as Record<string, ZdtpColorScheme>` double-cast, every local
12
+ * scheme map is run through `toZdtpColorSchemes()` below, which supplies
13
+ * `DEFAULT_SHIKI_THEME` as the fallback so the result satisfies zdtp's
14
+ * required-field shape with an ordinary type-checked assignment.
14
15
  */
15
16
 
16
17
  import type {
@@ -28,6 +29,7 @@ import {
28
29
  SIZE_TOKENS,
29
30
  } from "./design-tokens-manifest";
30
31
  import { colorSchemes } from "./color-schemes";
32
+ import type { ColorScheme as LocalColorScheme } from "./color-schemes";
31
33
  import { SEMANTIC_DEFAULTS, SEMANTIC_CSS_NAMES } from "./color-scheme-utils";
32
34
  import { settings } from "./settings";
33
35
  import { DESIGN_TOKEN_SCHEMA } from "@takazudo/zudo-doc/theme";
@@ -49,6 +51,30 @@ const BASE_DEFAULTS = {
49
51
  */
50
52
  const DEFAULT_SHIKI_THEME = "github-dark";
51
53
 
54
+ /**
55
+ * Normalize this project's local `ColorScheme` records into zdtp's
56
+ * `ColorScheme` shape. zdtp's type requires `shikiTheme: string`; the local
57
+ * scheme type makes it optional. This helper fills `DEFAULT_SHIKI_THEME` only
58
+ * when a scheme doesn't declare its own, so the result is assignable to
59
+ * `Record<string, ZdtpColorScheme>` via an ordinary type-checked assignment —
60
+ * replacing the previous `as unknown as` double-cast that bypassed every field
61
+ * check. Tracked upstream at Takazudo/zudo-design-token-panel#342 (shikiTheme
62
+ * should be optional in zdtp's `ColorScheme` type); drop this helper once that
63
+ * lands.
64
+ */
65
+ function toZdtpColorSchemes(
66
+ schemes: Record<string, LocalColorScheme>,
67
+ ): Record<string, ZdtpColorScheme> {
68
+ const normalized: Record<string, ZdtpColorScheme> = {};
69
+ for (const [name, scheme] of Object.entries(schemes)) {
70
+ normalized[name] = {
71
+ ...scheme,
72
+ shikiTheme: scheme.shikiTheme ?? DEFAULT_SHIKI_THEME,
73
+ };
74
+ }
75
+ return normalized;
76
+ }
77
+
52
78
  /**
53
79
  * Initial palette taken from the configured active scheme.
54
80
  */
@@ -178,9 +204,9 @@ const COLOR_EXTRAS: ColorClusterExtras = {
178
204
  },
179
205
  baseDefaults: BASE_DEFAULTS,
180
206
  defaultShikiTheme: DEFAULT_SHIKI_THEME,
181
- // Local ColorScheme lacks shikiTheme; cast is safe zdtp falls back to
182
- // defaultShikiTheme when shikiTheme is absent at runtime.
183
- colorSchemes: colorSchemes as unknown as Record<string, ZdtpColorScheme>,
207
+ // toZdtpColorSchemes fills the fallback only for schemes without their own
208
+ // shikiTheme, so this is a type-checked assignment rather than an unsafe cast.
209
+ colorSchemes: toZdtpColorSchemes(colorSchemes),
184
210
  panelSettings: {
185
211
  colorScheme: settings.colorScheme,
186
212
  // colorMode: strip off respectPrefersColorScheme (not in zdtp's shape).
@@ -1,12 +1,12 @@
1
+ // @ts-check
1
2
  // zfb plugin module: doc-history.
2
3
  //
3
4
  // Wires three lifecycle hooks for the doc-history integration:
4
5
  //
5
6
  // preBuild — emits `.zfb/doc-history-meta.json` (per-page git metadata
6
- // consumed at bundle time). Uses the tsx-e shim because the
7
- // runner imports Node-only modules (`fs`, `child_process`) and
8
- // TypeScript source that the plain-Node plugin host cannot load
9
- // directly. Honours `SKIP_DOC_HISTORY=1` via `env: process.env`.
7
+ // consumed at bundle time). Calls `runDocHistoryMetaStep`
8
+ // directly; honours `SKIP_DOC_HISTORY=1` via the runner's
9
+ // own env check.
10
10
  //
11
11
  // postBuild — invokes `runDocHistoryPostBuild` to write
12
12
  // `<outDir>/doc-history/<slug>.json` files. Skipped by default
@@ -20,69 +20,45 @@
20
20
  // Inline functions are not supported by zfb's plugin runtime — see
21
21
  // `@takazudo/zfb/plugins` source comment. Plugins must be authored as
22
22
  // standalone modules and referenced from `zfb.config.ts` by `name`.
23
- //
24
- // The legacy `scripts/zfb-{pre,post}build.mjs` npm-script glue and
25
- // `scripts/dev-sidecar.mjs` stay in place during the merge window;
26
- // T6 retires them once all lifecycle epics land.
27
23
 
28
- import { spawnSync } from "node:child_process";
29
- import { fileURLToPath } from "node:url";
30
- import { dirname, resolve } from "node:path";
24
+ /** @import { ZfbBuildHookContext, ZfbDevMiddlewareContext, ZfbPlugin } from "@takazudo/zfb/plugins" */
31
25
 
26
+ import { runDocHistoryMetaStep } from "@takazudo/zudo-doc/integrations/doc-history";
32
27
  import { runDocHistoryPostBuild } from "@takazudo/zudo-doc/integrations/doc-history";
33
28
  import { createDocHistoryDevMiddleware } from "@takazudo/zudo-doc/integrations/doc-history";
34
29
  import { connectToZfbHandler } from "./connect-adapter.mjs";
35
30
 
36
- const HERE = dirname(fileURLToPath(import.meta.url));
37
- // tsx is a workspace dep; resolving the binary explicitly avoids PATH
38
- // dependency — the plugin host is spawned by zfb without the user's shell profile.
39
- const TSX_BIN = resolve(HERE, "..", "node_modules", ".bin", "tsx");
40
-
31
+ /** @type {ZfbPlugin} */
41
32
  export default {
42
33
  name: "doc-history",
43
34
 
35
+ /** @param {ZfbBuildHookContext} ctx */
44
36
  async preBuild(ctx) {
45
37
  const { docsDir, locales } = ctx.options;
46
- // Serialize options as JSON for the tsx-e inline script. The runner
47
- // (`runDocHistoryMetaStep`) honours SKIP_DOC_HISTORY=1 internally;
48
- // passing `env: process.env` propagates the flag to the child process.
49
- const optsJson = JSON.stringify({
38
+ await runDocHistoryMetaStep({
50
39
  projectRoot: ctx.projectRoot,
51
40
  docsDir: typeof docsDir === "string" ? docsDir : "src/content/docs",
52
- locales: locales ?? {},
53
- });
54
- const script = `
55
- (async () => {
56
- const { runDocHistoryMetaStep } = await import("@takazudo/zudo-doc/integrations/doc-history");
57
- const opts = ${optsJson};
58
- await runDocHistoryMetaStep(opts);
59
- })().catch((err) => {
60
- process.stderr.write(err && err.stack ? err.stack : String(err));
61
- process.exit(1);
62
- });
63
- `;
64
- const result = spawnSync(TSX_BIN, ["-e", script], {
65
- cwd: ctx.projectRoot,
66
- stdio: "inherit",
67
- env: process.env,
41
+ locales:
42
+ locales != null
43
+ ? /** @type {Record<string,{dir:string}>} */ (locales)
44
+ : undefined,
68
45
  });
69
- if (result.status !== 0) {
70
- throw new Error(
71
- `doc-history-meta preBuild failed (exit ${result.status})`,
72
- );
73
- }
74
46
  },
75
47
 
48
+ /** @param {ZfbBuildHookContext} ctx */
76
49
  async postBuild(ctx) {
77
- const { docsDir, locales } = ctx.options;
78
50
  await runDocHistoryPostBuild(
79
- { docsDir, locales },
51
+ /** @type {import("@takazudo/zudo-doc/integrations/doc-history").DocHistoryOptions} */ (/** @type {unknown} */ (ctx.options)),
80
52
  { outDir: ctx.outDir, logger: ctx.logger },
81
53
  );
82
54
  },
83
55
 
56
+ /** @param {ZfbDevMiddlewareContext} ctx */
84
57
  devMiddleware(ctx) {
85
- const middleware = createDocHistoryDevMiddleware(ctx.options, ctx.logger);
58
+ const middleware = createDocHistoryDevMiddleware(
59
+ /** @type {import("@takazudo/zudo-doc/integrations/doc-history").DocHistoryOptions} */ (/** @type {unknown} */ (ctx.options)),
60
+ ctx.logger,
61
+ );
86
62
  // zfb's `register(path, handler)` matches against the FULL request
87
63
  // URL (no base-stripping). For a non-root base (e.g. "/my-docs/"),
88
64
  // requests arrive as `/my-docs/doc-history/foo.json`, so we register
@@ -90,11 +66,17 @@ export default {
90
66
  // and the route is `/doc-history` as expected. The v2 middleware
91
67
  // itself is base-tolerant (matches via `url.includes("/doc-history/")`)
92
68
  // and slices from `/doc-history/` onward when proxying upstream.
93
- const basePrefix = stripTrailingSlash(ctx.options.base ?? "");
69
+ const basePrefix = stripTrailingSlash(
70
+ typeof ctx.options["base"] === "string" ? ctx.options["base"] : "",
71
+ );
94
72
  ctx.register(`${basePrefix}/doc-history`, connectToZfbHandler(middleware));
95
73
  },
96
74
  };
97
75
 
76
+ /**
77
+ * @param {string} s
78
+ * @returns {string}
79
+ */
98
80
  function stripTrailingSlash(s) {
99
81
  if (typeof s !== "string" || s.length === 0) return "";
100
82
  return s.endsWith("/") ? s.slice(0, -1) : s;
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useCallback, useMemo, useRef } from "preact/compat";
2
- import { diffLines } from "diff";
2
+ import type { Change } from "diff";
3
3
  import type { DocHistoryData, DocHistoryEntry } from "@/types/doc-history";
4
4
  import { SmartBreak } from "@/utils/smart-break";
5
5
 
@@ -104,8 +104,10 @@ interface DiffRow {
104
104
  type: "context" | "removed" | "added" | "changed";
105
105
  }
106
106
 
107
+ type DiffChanges = Change[];
108
+
107
109
  function buildSideBySideRows(
108
- changes: ReturnType<typeof diffLines>,
110
+ changes: DiffChanges,
109
111
  ): DiffRow[] {
110
112
  const rows: DiffRow[] = [];
111
113
  let leftNum = 0;
@@ -177,11 +179,24 @@ function DiffViewer({
177
179
  onBack: () => void;
178
180
  showBackButton: boolean;
179
181
  }) {
180
- const changes = useMemo(
181
- () => diffLines(selection.older.content, selection.newer.content),
182
- [selection.older.content, selection.newer.content],
182
+ const [changes, setChanges] = useState<DiffChanges | null>(null);
183
+
184
+ useEffect(() => {
185
+ let cancelled = false;
186
+ // Lazy-load diff — only needed after History → Compare. This keeps the
187
+ // module out of the eager islands bundle.
188
+ import("diff").then(({ diffLines }) => {
189
+ if (!cancelled) {
190
+ setChanges(diffLines(selection.older.content, selection.newer.content));
191
+ }
192
+ });
193
+ return () => { cancelled = true; };
194
+ }, [selection.older.content, selection.newer.content]);
195
+
196
+ const rows = useMemo(
197
+ () => (changes ? buildSideBySideRows(changes) : []),
198
+ [changes],
183
199
  );
184
- const rows = useMemo(() => buildSideBySideRows(changes), [changes]);
185
200
 
186
201
  return (
187
202
  <div className="flex flex-col h-full">
@@ -207,8 +222,13 @@ function DiffViewer({
207
222
  </div>
208
223
  </div>
209
224
 
210
- {/* Side-by-side diff */}
211
- <div className="flex-1 overflow-auto">
225
+ {/* Side-by-side diff — shows a loading state while the diff module lazy-loads */}
226
+ {!changes && (
227
+ <div className="flex-1 flex items-center justify-center py-vsp-xl">
228
+ <p className="text-small text-muted">Loading diff…</p>
229
+ </div>
230
+ )}
231
+ <div className={`flex-1 overflow-auto${!changes ? " hidden" : ""}`}>
212
232
  <table className="w-full border-collapse" style={{ tableLayout: "fixed" }}>
213
233
  <colgroup>
214
234
  <col style={{ width: "2.5rem" }} />
@@ -11,29 +11,14 @@
11
11
  // params: { locale: string; tag: string }
12
12
  // props: { tagInfo: TagInfo }
13
13
  //
14
+ // Tag collection + rendering are shared with the default-locale route via
15
+ // pages/lib/_tag-pages.tsx (#2010) — this file owns only the param shape.
14
16
  // Fallback strategy: see pages/lib/locale-merge.ts for full details.
15
- // Locale docs take priority; base docs fill in missing slugs.
16
- // Build tag map; emit one route per (locale, tag) pair.
17
17
 
18
- import { mergeLocaleDocs } from "../../../lib/locale-merge";
19
- import { loadDocs } from "../../../_data";
20
- import { collectTags } from "@/utils/tags";
21
- import type { TagInfo } from "@/utils/tags";
22
- import { toRouteSlug } from "@/utils/slug";
23
- import { t } from "@/config/i18n";
24
- import { withBase, docsUrl } from "@/utils/base";
25
18
  import { settings } from "@/config/settings";
26
- import { DocLayoutWithDefaults } from "@takazudo/zudo-doc/doclayout";
27
- import { Breadcrumb } from "@takazudo/zudo-doc/breadcrumb";
28
- import type { BreadcrumbItem } from "@takazudo/zudo-doc/breadcrumb";
29
- import { DocCardGrid } from "@takazudo/zudo-doc/nav-indexing";
19
+ import type { TagInfo } from "@/utils/tags";
30
20
  import type { JSX } from "preact";
31
- import { FooterWithDefaults } from "../../../lib/_footer-with-defaults";
32
- import { HeaderWithDefaults } from "../../../lib/_header-with-defaults";
33
- import { HeadWithDefaults } from "../../../lib/_head-with-defaults";
34
- import { composeMetaTitle } from "../../../lib/_compose-meta-title";
35
- import { DocHistoryArea } from "../../../lib/_doc-history-area";
36
- import { BodyEndIslands } from "../../../lib/_body-end-islands";
21
+ import { collectTagMapForLocale, TagDetailPageView } from "../../../lib/_tag-pages";
37
22
 
38
23
  export const frontmatter = { title: "Tag" };
39
24
 
@@ -48,18 +33,7 @@ export function paths(): Array<{
48
33
  }> = [];
49
34
 
50
35
  for (const locale of Object.keys(settings.locales)) {
51
- const { docs: mergedDocs } = mergeLocaleDocs({
52
- baseDocs: loadDocs("docs").filter((d) => !d.data.draft),
53
- localeDocs: loadDocs(`docs-${locale}`).filter((d) => !d.data.draft),
54
- applyDefaultLocaleOnlyFilter: true,
55
- });
56
- // category_no_page index files build no route — drop them AFTER the merge
57
- // so a locale override carrying the flag first wins the merge (suppressing
58
- // the base doc); pre-merge filtering would drop it from localeSlugSet and
59
- // the unflagged base doc would resurface as a card linking to a locale
60
- // route the docs route never builds.
61
- const docs = mergedDocs.filter((d) => !d.data.category_no_page);
62
- const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
36
+ const tagMap = collectTagMapForLocale(locale);
63
37
 
64
38
  for (const [tag, tagInfo] of tagMap.entries()) {
65
39
  result.push({
@@ -81,47 +55,5 @@ export default function LocaleDocTagPage({
81
55
  params,
82
56
  tagInfo,
83
57
  }: PageProps): JSX.Element {
84
- const { locale, tag } = params;
85
-
86
- const countText =
87
- tagInfo.count === 1
88
- ? t("doc.pageCountSingle", locale).replace("{count}", String(tagInfo.count))
89
- : t("doc.pageCount", locale).replace("{count}", String(tagInfo.count));
90
-
91
- const pageTitle = `${t("doc.taggedWith", locale)}: ${tag}`;
92
-
93
- const breadcrumbItems: BreadcrumbItem[] = [
94
- { label: "Docs" },
95
- {
96
- label: t("doc.allTags", locale),
97
- href: withBase(`/${locale}/docs/tags`),
98
- },
99
- { label: tag },
100
- ];
101
-
102
- const cardItems = tagInfo.docs.map((doc) => ({
103
- href: docsUrl(doc.slug, locale),
104
- title: doc.title,
105
- description: doc.description,
106
- }));
107
-
108
- return (
109
- <DocLayoutWithDefaults
110
- title={composeMetaTitle(pageTitle)}
111
- head={<HeadWithDefaults title={pageTitle} />}
112
- lang={locale}
113
- noindex={settings.noindex}
114
- hideSidebar={true}
115
- hideToc={true}
116
- headerOverride={<HeaderWithDefaults lang={locale} currentPath={withBase(`/${locale}/docs/tags/${tag}`)} />}
117
- breadcrumbOverride={<Breadcrumb items={breadcrumbItems} />}
118
- footerOverride={<FooterWithDefaults lang={locale} />}
119
- bodyEndComponents={<BodyEndIslands basePath={settings.base ?? "/"} />}
120
- >
121
- <h1 class="text-heading font-bold mb-vsp-xs">{pageTitle}</h1>
122
- <p class="text-muted mb-vsp-lg">{countText}</p>
123
- <DocCardGrid ariaLabel={pageTitle} items={cardItems} />
124
- <DocHistoryArea slug={`tags/${tag}`} locale={locale} />
125
- </DocLayoutWithDefaults>
126
- );
58
+ return <TagDetailPageView locale={params.locale} tag={params.tag} tagInfo={tagInfo} />;
127
59
  }
@@ -3,36 +3,21 @@
3
3
  // Page module for the locale-prefixed "All Tags" index route.
4
4
  //
5
5
  // Non-default-locale "All Tags" index page. paths() emits one route per
6
- // locale defined in settings.locales (English has no /en prefix — it is
7
- // handled by pages/docs/tags/index.tsx). The component recomputes the tag
6
+ // locale defined in settings.locales (the default locale has no prefix — it
7
+ // is handled by pages/docs/tags/index.tsx). The component recomputes the tag
8
8
  // map at render time using a locale-doc + base-doc fallback strategy
9
9
  // (see pages/lib/locale-merge.ts for the merge logic).
10
10
  //
11
- // Fallback strategy (locale first, base as fill): see pages/lib/locale-merge.ts
11
+ // Tag collection + rendering are shared with the default-locale route via
12
+ // pages/lib/_tag-pages.tsx (#2010).
12
13
  //
13
14
  // paths() contract (zfb ADR-004 — synchronous):
14
15
  // params: { locale: string }
15
16
  // props: (none — tag map computed at render time)
16
17
 
17
- import { mergeLocaleDocs } from "../../../lib/locale-merge";
18
- import { loadDocs } from "../../../_data";
19
- import { collectTags } from "@/utils/tags";
20
- import { toRouteSlug } from "@/utils/slug";
21
- import { t } from "@/config/i18n";
22
- import { withBase } from "@/utils/base";
23
18
  import { settings } from "@/config/settings";
24
- import { DocLayoutWithDefaults } from "@takazudo/zudo-doc/doclayout";
25
- import { Breadcrumb } from "@takazudo/zudo-doc/breadcrumb";
26
- import type { BreadcrumbItem } from "@takazudo/zudo-doc/breadcrumb";
27
- import { TagNav } from "@takazudo/zudo-doc/nav-indexing";
28
- import type { TagItem, TagNavLabels } from "@takazudo/zudo-doc/nav-indexing";
29
19
  import type { JSX } from "preact";
30
- import { FooterWithDefaults } from "../../../lib/_footer-with-defaults";
31
- import { HeaderWithDefaults } from "../../../lib/_header-with-defaults";
32
- import { HeadWithDefaults } from "../../../lib/_head-with-defaults";
33
- import { composeMetaTitle } from "../../../lib/_compose-meta-title";
34
- import { DocHistoryArea } from "../../../lib/_doc-history-area";
35
- import { BodyEndIslands } from "../../../lib/_body-end-islands";
20
+ import { TagsIndexPageView } from "../../../lib/_tag-pages";
36
21
 
37
22
  export const frontmatter = { title: "All Tags" };
38
23
 
@@ -50,61 +35,5 @@ interface PageProps {
50
35
  export default function LocaleTagsIndexPage({
51
36
  params,
52
37
  }: PageProps): JSX.Element {
53
- const { locale } = params;
54
- const pageTitle = t("doc.allTags", locale);
55
-
56
- const { docs: mergedDocs } = mergeLocaleDocs({
57
- baseDocs: loadDocs("docs").filter((d) => !d.data.draft),
58
- localeDocs: loadDocs(`docs-${locale}`).filter((d) => !d.data.draft),
59
- applyDefaultLocaleOnlyFilter: true,
60
- });
61
- // category_no_page index files build no route — drop them AFTER the merge
62
- // so a locale override carrying the flag first wins the merge (suppressing
63
- // the base doc); pre-merge filtering would drop it from localeSlugSet and
64
- // the unflagged base doc would resurface as a card linking to a locale
65
- // route the docs route never builds.
66
- const docs = mergedDocs.filter((d) => !d.data.category_no_page);
67
- const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
68
-
69
- const labels: TagNavLabels = {
70
- tags: t("doc.tags", locale),
71
- taggedWith: t("doc.taggedWith", locale),
72
- };
73
-
74
- // Sort alphabetically using the page locale — matches documented tag-nav sort order.
75
- const tags: TagItem[] = [...tagMap.values()]
76
- .sort((a, b) => a.tag.localeCompare(b.tag, locale))
77
- .map((info) => ({
78
- tag: info.tag,
79
- count: info.count,
80
- href: withBase(`/${locale}/docs/tags/${info.tag}`),
81
- }));
82
-
83
- const breadcrumbItems: BreadcrumbItem[] = [
84
- { label: "Docs" },
85
- { label: pageTitle },
86
- ];
87
-
88
- return (
89
- <DocLayoutWithDefaults
90
- title={composeMetaTitle(pageTitle)}
91
- head={<HeadWithDefaults title={pageTitle} />}
92
- lang={locale}
93
- noindex={settings.noindex}
94
- hideSidebar={true}
95
- hideToc={true}
96
- headerOverride={<HeaderWithDefaults lang={locale} currentPath={withBase(`/${locale}/docs/tags`)} />}
97
- breadcrumbOverride={<Breadcrumb items={breadcrumbItems} />}
98
- footerOverride={<FooterWithDefaults lang={locale} />}
99
- bodyEndComponents={<BodyEndIslands basePath={settings.base ?? "/"} />}
100
- >
101
- <h1 class="text-heading font-bold mb-vsp-lg">{pageTitle}</h1>
102
- {!settings.docTags || tags.length === 0 ? (
103
- <p class="text-muted">{t("doc.noTags", locale)}</p>
104
- ) : (
105
- <TagNav variant="all" tags={tags} labels={labels} />
106
- )}
107
- <DocHistoryArea slug="tags" locale={locale} />
108
- </DocLayoutWithDefaults>
109
- );
38
+ return <TagsIndexPageView locale={params.locale} />;
110
39
  }