create-zudo-doc 0.2.0-next.6 → 0.2.0-next.8

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 (51) hide show
  1. package/dist/constants.d.ts +2 -0
  2. package/dist/constants.js +26 -0
  3. package/dist/scaffold.js +11 -5
  4. package/dist/settings-gen.js +9 -0
  5. package/dist/zfb-config-gen.js +17 -0
  6. package/package.json +1 -1
  7. package/templates/base/pages/_data.ts +28 -11
  8. package/templates/base/pages/docs/[[...slug]].tsx +57 -129
  9. package/templates/base/pages/index.tsx +3 -1
  10. package/templates/base/pages/lib/_category-nav.tsx +13 -9
  11. package/templates/base/pages/lib/_doc-history-area.tsx +22 -2
  12. package/templates/base/pages/lib/_doc-metainfo-area.tsx +6 -6
  13. package/templates/base/pages/lib/_doc-page-shell.tsx +229 -0
  14. package/templates/base/pages/lib/_doc-route-paths.ts +101 -0
  15. package/templates/base/pages/lib/_extract-headings.ts +263 -33
  16. package/templates/base/pages/lib/_footer-with-defaults.tsx +9 -3
  17. package/templates/base/pages/lib/_head-with-defaults.tsx +15 -14
  18. package/templates/base/pages/lib/_nav-source-cache.ts +3 -3
  19. package/templates/base/pages/lib/_nav-source-docs.ts +9 -9
  20. package/templates/base/pages/lib/locale-merge.ts +15 -8
  21. package/templates/base/pages/lib/route-enumerators.ts +18 -3
  22. package/templates/base/src/components/client-router-bootstrap.tsx +55 -5
  23. package/templates/base/src/components/sidebar-toggle.tsx +106 -48
  24. package/templates/base/src/components/theme-toggle.tsx +15 -1
  25. package/templates/base/src/config/color-scheme-utils.ts +5 -3
  26. package/templates/base/src/config/frontmatter-preview-defaults.ts +2 -0
  27. package/templates/base/src/styles/global.css +38 -11
  28. package/templates/base/src/types/docs-entry.ts +7 -0
  29. package/templates/base/src/utils/base.ts +13 -1
  30. package/templates/base/src/utils/dedent.ts +1 -1
  31. package/templates/base/src/utils/docs.ts +62 -9
  32. package/templates/base/src/utils/smart-break.tsx +2 -2
  33. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +172 -13
  34. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +34 -12
  35. package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +1 -0
  36. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +4 -2
  37. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +7 -1
  38. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +7 -1
  39. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +8 -4
  40. package/templates/features/docTags/files/pages/docs/tags/index.tsx +8 -4
  41. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +57 -126
  42. package/templates/features/i18n/files/pages/[locale]/index.tsx +3 -1
  43. package/templates/features/tagGovernance/files/scripts/tags-audit.ts +4 -1
  44. package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +65 -125
  45. package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +66 -131
  46. package/templates/base/src/components/html-preview/highlighted-code.tsx +0 -74
  47. package/templates/base/src/components/html-preview/html-preview.tsx +0 -108
  48. package/templates/base/src/components/html-preview/preflight.ts +0 -112
  49. package/templates/base/src/components/html-preview/preview-base.tsx +0 -159
  50. package/templates/base/src/components/mobile-toc.tsx +0 -94
  51. package/templates/base/src/components/toc.tsx +0 -63
@@ -5,6 +5,7 @@ export interface LightDarkPairing {
5
5
  }
6
6
  export declare const LIGHT_DARK_PAIRINGS: LightDarkPairing[];
7
7
  export declare const SINGLE_SCHEMES: string[];
8
+ export declare const LIGHT_SCHEMES: string[];
8
9
  export interface SupportedLang {
9
10
  value: string;
10
11
  label: string;
@@ -18,3 +19,4 @@ export interface Feature {
18
19
  cliFlag: string;
19
20
  }
20
21
  export declare const FEATURES: Feature[];
22
+ export declare const HEADER_RIGHT_LABELS: Record<string, string>;
package/dist/constants.js CHANGED
@@ -55,6 +55,19 @@ export const SINGLE_SCHEMES = [
55
55
  "Gruvbox Light",
56
56
  "Ayu Light",
57
57
  ];
58
+ // Light-only subset of SINGLE_SCHEMES. Used by the preset generator to populate
59
+ // the "Light scheme" dropdown (dark schemes are derived as SINGLE_SCHEMES minus these).
60
+ export const LIGHT_SCHEMES = [
61
+ "Default Light",
62
+ "GitHub Light",
63
+ "Catppuccin Latte",
64
+ "Solarized Light",
65
+ "Rose Pine Dawn",
66
+ "Atom One Light",
67
+ "Everforest Light",
68
+ "Gruvbox Light",
69
+ "Ayu Light",
70
+ ];
58
71
  export const SUPPORTED_LANGS = [
59
72
  { value: "en", label: "English" },
60
73
  { value: "ja", label: "Japanese" },
@@ -222,3 +235,16 @@ export const FEATURES = [
222
235
  cliFlag: "footer-taglist",
223
236
  },
224
237
  ];
238
+ // Display labels for header-right items. Keys are canonical component/trigger
239
+ // names from HeaderRightComponentName / HeaderRightTriggerName
240
+ // (src/config/settings-types.ts in the host); they are deliberately not imported
241
+ // here so constants.ts stays pure data with no cross-package dependencies.
242
+ export const HEADER_RIGHT_LABELS = {
243
+ "version-switcher": "Version switcher",
244
+ "design-token-panel": "Design token panel (trigger)",
245
+ "ai-chat": "AI chat (trigger)",
246
+ "github-link": "GitHub link",
247
+ "theme-toggle": "Theme toggle",
248
+ search: "Search",
249
+ "language-switcher": "Language switcher",
250
+ };
package/dist/scaffold.js CHANGED
@@ -258,16 +258,22 @@ function generatePackageJson(choices) {
258
258
  // disabled, reproducible CSS-Modules scoped names (project-relative paths),
259
259
  // dev-mode git-restore detection, Tailwind temp-file cleanup, and a
260
260
  // near-miss `"use client"` directive scanner.
261
- "@takazudo/zfb": "0.1.0-next.31",
262
- "@takazudo/zfb-runtime": "0.1.0-next.31",
261
+ // next.33 added the opt-in hierarchical heading-ID strategy
262
+ // (Takazudo/zudo-front-builder#871): `markdown.features.headingIds.strategy`.
263
+ // The generated config + TOC builder use it via settings.headingIdStrategy.
264
+ // next.35 fixes resolve_links rewriting bare same-page `[text](#anchor)` /
265
+ // `[text](?query)` links to `/<parent-dir>/#anchor` (zudolab/zudo-doc#1948,
266
+ // upstream Takazudo/zudo-front-builder#875).
267
+ "@takazudo/zfb": "0.1.0-next.35",
268
+ "@takazudo/zfb-runtime": "0.1.0-next.35",
263
269
  // zfb-adapter-cloudflare — required for any route with `prerender = false`.
264
270
  // Pinned in lockstep with @takazudo/zfb.
265
- "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.31",
271
+ "@takazudo/zfb-adapter-cloudflare": "0.1.0-next.35",
266
272
  // @takazudo/zudo-doc — published from this monorepo via
267
273
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
268
274
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
269
275
  // version moves, so a fresh scaffold pulls the version we just published.
270
- "@takazudo/zudo-doc": "^0.2.0-next.6",
276
+ "@takazudo/zudo-doc": "^0.2.0-next.8",
271
277
  // zod — used by the generated zfb.config.ts. zfb-config-gen emits
272
278
  // `import { z } from "zod"` for the content-collection schema +
273
279
  // `z.toJSONSchema(...)` conversion. Without this dep, the consumer
@@ -325,7 +331,7 @@ function generatePackageJson(choices) {
325
331
  // @takazudo/zudo-doc/integrations/doc-history which in turn imports
326
332
  // @takazudo/zudo-doc-history-server/git-history. Without this dep the
327
333
  // plugin host fails at init with ERR_MODULE_NOT_FOUND — W8A (#1739).
328
- deps["@takazudo/zudo-doc-history-server"] = "^0.2.0-next.6";
334
+ deps["@takazudo/zudo-doc-history-server"] = "^0.2.0-next.8";
329
335
  // W7A (#1736): doc-history-plugin.mjs spawns `tsx -e <inline-script>` to
330
336
  // run the v2 runtime in a TS-aware Node subprocess; without tsx the
331
337
  // plugin's preBuild step exits with ENOENT before zfb finishes config
@@ -130,6 +130,15 @@ export function generateSettingsFile(choices) {
130
130
  else {
131
131
  lines.push(` designTokenPanel: false as boolean,`);
132
132
  }
133
+ lines.push(` tocMinDepth: 2 as number,`);
134
+ lines.push(` tocMaxDepth: 4 as number,`);
135
+ // Heading-ID (anchor) strategy — single source of truth shared by
136
+ // zfb.config.ts (markdown.features.headingIds) and the host TOC builder
137
+ // (pages/lib/_extract-headings.ts). "hierarchical" emits ancestor-prefixed
138
+ // anchors (foo / foo-moo / foo-moo-mew); "flat" is zfb's legacy scheme.
139
+ // Default to "hierarchical": safe for greenfield (no existing deep links to
140
+ // break) and the recommended scheme (upstream zfb#871).
141
+ lines.push(` headingIdStrategy: "hierarchical" as "flat" | "hierarchical",`);
133
142
  if (choices.features.includes("sidebarResizer")) {
134
143
  lines.push(` sidebarResizer: true as boolean,`);
135
144
  }
@@ -71,6 +71,8 @@ export function generateZfbConfig(choices) {
71
71
  lines.push(` standalone: z.boolean().optional(),`);
72
72
  lines.push(` slug: z.string().optional(),`);
73
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(),`);
74
76
  lines.push(` })`);
75
77
  lines.push(` .passthrough();`);
76
78
  lines.push(``);
@@ -152,6 +154,7 @@ export function generateZfbConfig(choices) {
152
154
  lines.push(` options: {`);
153
155
  lines.push(` docsDir: settings.docsDir,`);
154
156
  lines.push(` locales: localeRecord,`);
157
+ lines.push(` base: settings.base,`);
155
158
  lines.push(` },`);
156
159
  lines.push(` },`);
157
160
  lines.push(` ]`);
@@ -211,6 +214,16 @@ export function generateZfbConfig(choices) {
211
214
  lines.push(` dir: locale.dir,`);
212
215
  lines.push(` routePrefix: \`/\${code}/docs/\`,`);
213
216
  lines.push(` })),`);
217
+ lines.push(` // Versioned collections: each version's EN dir + per-locale dirs.`);
218
+ lines.push(` ...(settings.versions`);
219
+ lines.push(` ? settings.versions.flatMap((version) => [`);
220
+ lines.push(` { dir: version.docsDir, routePrefix: \`/v/\${version.slug}/docs/\` },`);
221
+ lines.push(` ...Object.entries(version.locales ?? {}).map(([code, locale]) => ({`);
222
+ lines.push(` dir: locale.dir,`);
223
+ lines.push(` routePrefix: \`/v/\${version.slug}/\${code}/docs/\`,`);
224
+ lines.push(` })),`);
225
+ lines.push(` ])`);
226
+ lines.push(` : []),`);
214
227
  lines.push(` ],`);
215
228
  lines.push(` onBrokenLinks: "warn",`);
216
229
  lines.push(` },`);
@@ -271,6 +284,10 @@ export function generateZfbConfig(choices) {
271
284
  lines.push(` imageDimensions: {},`);
272
285
  lines.push(` // warn-only link validation — failOnBroken: false never fails the build.`);
273
286
  lines.push(` linkValidation: { failOnBroken: false },`);
287
+ lines.push(` // Heading-ID (anchor) strategy — single source of truth in`);
288
+ lines.push(` // settings.headingIdStrategy, also mirrored by the host TOC builder`);
289
+ lines.push(` // (pages/lib/_extract-headings.ts) so TOC anchors match rendered ids.`);
290
+ lines.push(` headingIds: { strategy: settings.headingIdStrategy },`);
274
291
  lines.push(` },`);
275
292
  lines.push(` },`);
276
293
  lines.push(` plugins: integrationPlugins,`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "0.2.0-next.6",
3
+ "version": "0.2.0-next.8",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -13,6 +13,7 @@
13
13
  import { getCollection } from "zfb/content";
14
14
  import type { CollectionEntry } from "zfb/content";
15
15
  import type { DocsEntry } from "@/types/docs-entry";
16
+ import type { DocPageEntry } from "./lib/doc-page-props";
16
17
  import { toRouteSlug } from "@/utils/slug";
17
18
 
18
19
  // ---------------------------------------------------------------------------
@@ -79,7 +80,7 @@ export type ZfbDocsEntry = CollectionEntry<ZfbDocsData> & {
79
80
  * - `collection` — the collection name, for DocsEntry compat
80
81
  */
81
82
  export function getDocs(collectionName: string): ZfbDocsEntry[] {
82
- const entries = getCollection(collectionName) as unknown as CollectionEntry<ZfbDocsData>[];
83
+ const entries = getCollection<ZfbDocsData>(collectionName);
83
84
  return entries.map((e) => ({
84
85
  ...e,
85
86
  // Astro-compat: strip a trailing `/index` from the entry id so
@@ -123,27 +124,43 @@ export function bridgeEntries<T = ZfbDocsData>(
123
124
  }
124
125
 
125
126
  /**
126
- * Cast ZfbDocsEntry[] to DocsEntry[] for passing to @/utils/docs utilities.
127
+ * Typed bridge from a raw zfb collection result to `DocPageEntry[]`.
127
128
  *
128
- * The types are structurally compatible: ZfbDocsEntry has every required field
129
- * of DocsEntry (id, collection, data, body). The optional `rendered` and
130
- * `filePath` fields of DocsEntry are absent but not required.
129
+ * This is the **single, justified** cast at the zfb/DocsEntry boundary.
130
+ * `CollectionEntry<ZfbDocsData> & { id, collection }` structurally satisfies
131
+ * `DocPageEntry` because:
132
+ * - `id` and `collection` are added by `bridgeEntries`
133
+ * - `data` (ZfbDocsData) structurally satisfies `DocsEntry.data` (all
134
+ * required/optional fields are present; the index signature is wider)
135
+ * - `body`, `slug`, `module_specifier`, `Content` are provided by
136
+ * `CollectionEntry<ZfbDocsData>`
137
+ * The plain `as DocPageEntry[]` (not `as unknown as`) is intentional — it
138
+ * expresses that this is a well-understood structural subtype relationship,
139
+ * not an escape from the type system. The zfb type is the source of truth;
140
+ * DocsEntry/DocPageEntry are local compatibility shapes for @/utils/docs.
131
141
  */
132
- export function asDocsEntries(entries: ZfbDocsEntry[]): DocsEntry[] {
133
- return entries as unknown as DocsEntry[];
142
+ export function bridgeDocsEntries(
143
+ entries: ReadonlyArray<CollectionEntry<ZfbDocsData>>,
144
+ collectionName: string,
145
+ ): DocPageEntry[] {
146
+ return bridgeEntries(entries, collectionName) as DocPageEntry[];
134
147
  }
135
148
 
136
149
  /**
137
150
  * One-shot helper for paths()/render-time pages that just need a
138
- * `DocsEntry[]` for `@/utils/docs` consumption — wraps `getDocs` and
139
- * the `asDocsEntries` cast so call sites stay one-line. Use this from
140
- * any page that previously did
151
+ * `DocsEntry[]` for `@/utils/docs` consumption — wraps `getDocs` so
152
+ * call sites stay one-line. Use this from any page that previously did
141
153
  * `getCollection("docs") as unknown as DocsEntry[]` — that idiom
142
154
  * silently dropped the `id`/`collection` fields the utility helpers
143
155
  * read, which threw `Cannot read properties of undefined` at runtime.
156
+ *
157
+ * `ZfbDocsEntry` structurally satisfies `DocsEntry`: it carries `id`,
158
+ * `collection`, `data` (ZfbDocsData satisfies DocsEntry.data field-for-
159
+ * field), `body`, plus the zfb-specific extras (`slug`, `Content`, etc.)
160
+ * that DocsEntry does not require.
144
161
  */
145
162
  export function loadDocs(collectionName: string): DocsEntry[] {
146
- return asDocsEntries(getDocs(collectionName));
163
+ return getDocs(collectionName);
147
164
  }
148
165
 
149
166
  /**
@@ -22,44 +22,32 @@
22
22
  // Locale: defaultLocale (EN). Non-default locales are handled by
23
23
  // pages/[locale]/docs/[[...slug]].tsx.
24
24
 
25
- import type { DocsEntry } from "@/types/docs-entry";
26
25
  import { settings } from "@/config/settings";
27
26
  import { defaultLocale } from "@/config/i18n";
28
- import { docsUrl } from "@/utils/base";
27
+ import { docsUrl, absoluteUrl } from "@/utils/base";
29
28
  import {
30
29
  buildNavTree,
31
30
  buildBreadcrumbs,
32
- flattenTree,
33
- findNode,
34
31
  collectAutoIndexNodes,
35
32
  type NavNode,
36
33
  } from "@/utils/docs";
37
34
  import { getNavSectionForSlug, getNavSubtree } from "@/utils/nav-scope";
38
35
  import { toRouteSlug, toSlugParams } from "@/utils/slug";
39
- import { DocLayoutWithDefaults } from "@takazudo/zudo-doc/doclayout";
40
- import { Breadcrumb } from "@takazudo/zudo-doc/breadcrumb";
41
- import { NavCardGrid } from "@takazudo/zudo-doc/nav-indexing";
42
36
  // Shared MDX-tag → Preact-component bag. Includes htmlOverrides
43
37
  // (native typography), HtmlPreviewWrapper (Island), and stub bindings
44
38
  // for every other custom tag the MDX corpus references — see
45
39
  // `pages/_mdx-components.ts` for the full list and rationale.
46
40
  import { createMdxComponents } from "../_mdx-components";
47
- import { FooterWithDefaults } from "../lib/_footer-with-defaults";
48
41
  import { DocHistoryArea } from "../lib/_doc-history-area";
49
42
  import { DocMetainfoArea } from "../lib/_doc-metainfo-area";
50
- import { SidebarWithDefaults } from "../lib/_sidebar-with-defaults";
51
- import { HeaderWithDefaults } from "../lib/_header-with-defaults";
52
- import { HeadWithDefaults } from "../lib/_head-with-defaults";
53
- import { composeMetaTitle } from "../lib/_compose-meta-title";
54
43
  import { buildInlineVersionSwitcher } from "../lib/_inline-version-switcher";
55
44
  import type { JSX } from "preact";
56
45
  import { resolveNavSource } from "../lib/_nav-source-docs";
57
46
  import { extractHeadings } from "../lib/_extract-headings";
58
47
  import type { DocPageEntry, AutoIndexNode, DocPageEntryProps, DocPageAutoIndexProps } from "../lib/doc-page-props";
59
- import { DocPager } from "../lib/_doc-pager";
60
48
  import { DocContentHeader } from "../lib/_doc-content-header";
61
- import { SidebarPrepaint } from "../lib/_sidebar-prepaint";
62
- import { DocBodyEnd } from "../lib/_doc-body-end";
49
+ import { DocPageShell } from "../lib/_doc-page-shell";
50
+ import { resolveDocPrevNext, flattenSubtree } from "../lib/_doc-route-paths";
63
51
 
64
52
  export const frontmatter = { title: "Docs" };
65
53
 
@@ -94,40 +82,31 @@ export function paths(): Array<{
94
82
  const { docs, navDocs, categoryMeta } = resolveNavSource(locale, undefined);
95
83
 
96
84
  // Nav docs: exclude unlisted (for sidebar/prev-next) but keep for breadcrumbs
97
- const tree = buildNavTree(navDocs as unknown as DocsEntry[], locale, categoryMeta);
85
+ const tree = buildNavTree(navDocs, locale, categoryMeta);
98
86
  // Full tree (including unlisted) for accurate breadcrumbs
99
- const fullTree = buildNavTree(docs as unknown as DocsEntry[], locale, categoryMeta);
87
+ const fullTree = buildNavTree(docs, locale, categoryMeta);
100
88
 
101
89
  const result: Array<{ params: { slug: string[] }; props: DocPageProps }> = [];
102
90
 
103
91
  // Regular doc pages
104
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;
105
98
  const slug = entry.data.slug ?? toRouteSlug(entry.slug);
106
99
  const navSection = getNavSectionForSlug(slug);
107
100
  const subtree = getNavSubtree(tree, navSection);
108
- const flat = flattenTree(subtree);
109
- const idx = flat.findIndex((n) => n.slug === slug);
110
101
 
111
- let prevNode = idx > 0 ? flat[idx - 1] ?? null : null;
112
- let nextNode = idx >= 0 && idx < flat.length - 1 ? flat[idx + 1] ?? null : null;
113
-
114
- // Frontmatter pagination overrides
115
- if (entry.data.pagination_prev !== undefined) {
116
- if (entry.data.pagination_prev === null) {
117
- prevNode = null;
118
- } else {
119
- const found = findNode(tree, entry.data.pagination_prev);
120
- prevNode = found ?? prevNode;
121
- }
122
- }
123
- if (entry.data.pagination_next !== undefined) {
124
- if (entry.data.pagination_next === null) {
125
- nextNode = null;
126
- } else {
127
- const found = findNode(tree, entry.data.pagination_next);
128
- nextNode = found ?? nextNode;
129
- }
130
- }
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
+ );
131
110
 
132
111
  result.push({
133
112
  params: { slug: toSlugParams(slug) },
@@ -181,7 +160,8 @@ export default function DocsPage(props: PageArgs): JSX.Element {
181
160
  // locale so CategoryNav/CategoryTreeNav/SiteTreeNav query the right collection.
182
161
  const components = createMdxComponents(locale);
183
162
 
184
- // Resolve child hrefs for auto-index pages
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).
185
165
  const autoIndexChildren = props.kind === "autoIndex"
186
166
  ? props.autoIndex.children
187
167
  .filter((c: NavNode) => c.hasPage || c.children.length > 0)
@@ -191,12 +171,9 @@ export default function DocsPage(props: PageArgs): JSX.Element {
191
171
  }))
192
172
  : [];
193
173
 
194
- // Canonical URL — only when siteUrl is configured. pageUrl is the
195
- // base-prefixed path for this page without the siteUrl origin.
196
- const pageUrl = docsUrl(slug, locale);
197
- const canonical = settings.siteUrl
198
- ? settings.siteUrl.replace(/\/$/, "") + pageUrl
199
- : undefined;
174
+ // Canonical URL — base-prefixed page path, absolutized against siteUrl.
175
+ const currentPath = docsUrl(slug, locale);
176
+ const canonical = absoluteUrl(currentPath);
200
177
 
201
178
  // Persist key: locale + nav-section so the sidebar DOM node is reused
202
179
  // across same-locale + same-section navigations only. No sanitizer needed —
@@ -209,95 +186,46 @@ export default function DocsPage(props: PageArgs): JSX.Element {
209
186
  : `sidebar-${locale}-${navSection ?? "default"}`;
210
187
 
211
188
  return (
212
- <DocLayoutWithDefaults
213
- title={composeMetaTitle(title)}
189
+ <DocPageShell
190
+ kind={props.kind}
191
+ locale={locale}
192
+ slug={slug}
193
+ title={title}
214
194
  description={description}
215
- head={<HeadWithDefaults title={title} description={description} canonical={canonical} />}
216
- lang={locale}
217
- noindex={settings.noindex}
218
- hideSidebar={hideSidebar}
219
- hideToc={props.kind === "entry" ? props.entry.data.hide_toc : undefined}
220
- headings={headings}
221
195
  canonical={canonical}
196
+ breadcrumbs={breadcrumbs}
197
+ prev={prev}
198
+ next={next}
199
+ headings={headings}
200
+ navSection={navSection}
222
201
  sidebarPersistKey={sidebarPersistKey}
223
- headerOverride={
224
- <HeaderWithDefaults
225
- lang={locale}
226
- currentSlug={slug}
227
- navSection={getNavSectionForSlug(slug)}
228
- currentPath={docsUrl(slug, locale)}
229
- />
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
230
210
  }
231
- breadcrumbOverride={
232
- breadcrumbs.length > 0 ? (
233
- <Breadcrumb
234
- items={breadcrumbs}
235
- rightSlot={buildInlineVersionSwitcher(slug, locale)}
236
- />
211
+ contentHeaderSlot={
212
+ props.kind === "entry" ? (
213
+ <DocContentHeader entry={props.entry} slug={slug} locale={locale} />
237
214
  ) : undefined
238
215
  }
239
- sidebarOverride={
240
- <SidebarWithDefaults
241
- currentSlug={slug}
242
- lang={locale}
243
- navSection={getNavSectionForSlug(slug)}
244
- currentPath={docsUrl(slug, locale)}
245
- />
216
+ contentSlot={
217
+ props.kind === "entry" ? <props.entry.Content components={components} /> : undefined
246
218
  }
247
- afterSidebar={<SidebarPrepaint />}
248
- footerOverride={<FooterWithDefaults lang={locale} />}
249
- bodyEndComponents={<DocBodyEnd />}
250
- >
251
- {props.kind === "autoIndex" ? (
252
- /* Auto-index page: category without an index.mdx.
253
- Fragment (not <div>) so children become direct children of
254
- <article class="zd-content">, picking up the flow-space rule
255
- (.zd-content > :where(* + *) { margin-top: var(--flow-space) }).
256
- Wrapping in <div> would make h1/description p children-of-children
257
- and the flow gap (~24px) would never apply — see #1460. */
258
- <>
259
- <h1 class="text-heading font-bold mb-vsp-xs">{props.autoIndex.label}</h1>
260
-
261
- {/* Build-time date block — chrome parity (#1461). Auto-index pages
262
- previously rendered without doc-meta; reference site shows it on
263
- every docs page. The component returns null when no manifest
264
- entry exists for this slug. */}
265
- <DocMetainfoArea slug={slug} locale={locale} />
266
-
267
- {props.autoIndex.description && (
268
- <p class="mb-vsp-lg text-title text-muted">
269
- {props.autoIndex.description}
270
- </p>
271
- )}
272
- <NavCardGrid children={autoIndexChildren} />
273
- </>
274
- ) : (
275
- /* Regular doc page. Fragment (not <div>) for the same reason as
276
- the auto-index branch above — see #1460. */
277
- <>
278
- <DocContentHeader entry={props.entry} slug={slug} locale={locale} />
279
-
280
- {/* MDX content rendered via zfb's Content bridge */}
281
- <props.entry.Content components={components} />
282
-
283
- {/* Prev / Next pagination — placed before the document utilities
284
- section to match the Astro reference order: content → pager →
285
- view-source / history. In the Astro layout, BodyFootUtilArea was
286
- rendered by the doc-layout wrapper after the <slot /> content,
287
- so the pager (inside the slot) came first. Fixes #1535. */}
288
- <DocPager prev={prev} next={next} locale={locale} />
289
-
290
- {/* Document utilities (revision history + view-source link) — skipped for unlisted pages */}
291
- {!props.entry.data.unlisted && (
292
- <DocHistoryArea
293
- slug={slug}
294
- locale={locale}
295
- entrySlug={props.entry.slug}
296
- contentDir={settings.docsDir}
297
- />
298
- )}
299
- </>
300
- )}
301
- </DocLayoutWithDefaults>
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
+ />
302
230
  );
303
231
  }
@@ -46,8 +46,10 @@ export default function IndexPage(): JSX.Element {
46
46
  const categoryOrder = getCategoryOrder();
47
47
  const groupedTree = groupSatelliteNodes(tree, categoryOrder);
48
48
 
49
+ // Drop category_no_page index files so the count matches the number of tag
50
+ // pages actually built (the tag routes exclude them too).
49
51
  const tagCount = collectTags(
50
- navDocs,
52
+ navDocs.filter((d) => !d.data.category_no_page),
51
53
  (id, data) => data.slug ?? toRouteSlug(id),
52
54
  ).size;
53
55
 
@@ -21,9 +21,9 @@ import type { NavNode as V2NavNode } from "@takazudo/zudo-doc/nav-indexing/types
21
21
  import {
22
22
  buildNavTree,
23
23
  findNode,
24
+ firstRoutedHref,
24
25
  } from "@/utils/docs";
25
26
  import { defaultLocale, type Locale } from "@/config/i18n";
26
- import { docsUrl } from "@/utils/base";
27
27
  import { resolveNavSource } from "./_nav-source-docs";
28
28
 
29
29
  export interface CategoryNavWrapperProps {
@@ -39,8 +39,9 @@ export interface CategoryNavWrapperProps {
39
39
  * of "claude", not children). Each slug is resolved to its nav node; nodes
40
40
  * not found in the tree are silently skipped.
41
41
  *
42
- * For nodes with noPage=true (no index.mdx), the href falls back to the
43
- * auto-generated category index URL via docsUrl(slug, lang).
42
+ * A `category_no_page` category has no route of its own, so its card links to
43
+ * the first routed descendant page (via firstRoutedHref); categories with no
44
+ * reachable page are skipped rather than emitting a dead link.
44
45
  */
45
46
  categories?: string[];
46
47
  /**
@@ -61,8 +62,8 @@ export interface CategoryNavWrapperProps {
61
62
  * - `categories`: resolves an explicit list of top-level slugs as cards.
62
63
  * Use this when the target categories are siblings in the nav tree rather
63
64
  * than children of a common parent (e.g. claude-md / claude-skills are
64
- * top-level peers of claude, not children of it). Nodes with noPage=true
65
- * get their href computed via docsUrl() since auto-index pages exist.
65
+ * top-level peers of claude, not children of it). A noPage category card
66
+ * links to its first routed descendant page (it has no route of its own).
66
67
  *
67
68
  * Returns null when no visible children are resolved.
68
69
  */
@@ -85,14 +86,17 @@ export function CategoryNavWrapper({
85
86
  let children: V2NavNode[];
86
87
 
87
88
  if (categories !== undefined) {
88
- // Explicit slug list mode: resolve each slug to its nav node and build
89
- // a card for it. noPage nodes have no href in the tree but their
90
- // auto-generated category index page is reachable via docsUrl().
89
+ // Explicit slug list mode: resolve each slug to its nav node and build a
90
+ // card for it. A `category_no_page` category has no route of its own
91
+ // (collectAutoIndexNodes skips noPage nodes), so its card links to the
92
+ // first routed descendant page; categories with no reachable page are
93
+ // skipped rather than emitting a dead link.
91
94
  children = categories
92
95
  .map((slug): V2NavNode | null => {
93
96
  const node = findNode(tree, slug);
94
97
  if (!node) return null;
95
- const href = node.href ?? docsUrl(slug, locale);
98
+ const href = node.href ?? firstRoutedHref(node);
99
+ if (!href) return null;
96
100
  return {
97
101
  label: node.label,
98
102
  description: node.description,
@@ -67,6 +67,17 @@ interface DocHistoryAreaProps {
67
67
  * view-source GitHub URL. Omit to suppress the view-source link.
68
68
  */
69
69
  contentDir?: string;
70
+ /**
71
+ * True when this locale page falls back to the base EN collection
72
+ * (i.e. the slug has no translation for the active locale). When true,
73
+ * the history data-path derivations use defaultLocale so the island
74
+ * fetches the correct bare-slug JSON and the SSR manifest lookup hits
75
+ * the bare key — both of which only exist for EN-origin files.
76
+ * Display labels (t() calls) still use the active locale so JA users
77
+ * see JA labels on fallback pages. Omit (or false) for translated pages
78
+ * and all other call sites (EN route, tag pages) — behavior unchanged.
79
+ */
80
+ isFallback?: boolean;
70
81
  }
71
82
 
72
83
  /**
@@ -93,6 +104,7 @@ export function DocHistoryArea({
93
104
  locale,
94
105
  entrySlug,
95
106
  contentDir,
107
+ isFallback,
96
108
  }: DocHistoryAreaProps): VNode | null {
97
109
  if (!settings.docHistory) return null;
98
110
 
@@ -106,11 +118,18 @@ export function DocHistoryArea({
106
118
  // collectContentFiles walk in packages/doc-history-server. (#1891)
107
119
  const historySlug = toHistorySlug(slug);
108
120
 
121
+ // On EN-fallback locale pages the history data exists only at the bare
122
+ // (non-locale-prefixed) path — the prebuild/server writes locale-prefixed
123
+ // keys/paths only for files physically present in the locale collection.
124
+ // Use defaultLocale for data lookups when isFallback is true; keep locale
125
+ // for all display label calls (t()) so JA users see JA labels.
126
+ const effectiveHistoryLocale = isFallback ? defaultLocale : locale;
127
+
109
128
  // Look up the build-time manifest entry for this page. The composedSlug
110
129
  // matches the key written by the prebuild step: bare slug for the default
111
130
  // locale, "<localeKey>/<slug>" for non-default locales.
112
131
  const composedSlug =
113
- locale === defaultLocale ? historySlug : `${locale}/${historySlug}`;
132
+ effectiveHistoryLocale === defaultLocale ? historySlug : `${effectiveHistoryLocale}/${historySlug}`;
114
133
  type MetaEntry = { author: string; createdDate: string; updatedDate: string };
115
134
  const meta = (docHistoryMeta as Record<string, MetaEntry>)[composedSlug];
116
135
 
@@ -120,7 +139,8 @@ export function DocHistoryArea({
120
139
  const historyLabel = t("doc.history", locale);
121
140
 
122
141
  // Real-component props — locale omitted for the default locale.
123
- const docHistoryLocale = locale === defaultLocale ? undefined : locale;
142
+ // Use effectiveHistoryLocale so fallback pages fetch the bare (non-ja/) path.
143
+ const docHistoryLocale = effectiveHistoryLocale === defaultLocale ? undefined : effectiveHistoryLocale;
124
144
  const docHistoryBasePath = settings.base ?? "/";
125
145
 
126
146
  // Build the SSR fallback with only the sr-only metadata block so the
@@ -13,9 +13,10 @@
13
13
  // (b11-2 pattern).
14
14
  //
15
15
  // Date formatting uses Intl.DateTimeFormat (browser-safe). We do NOT
16
- // import `formatDate` from `src/utils/git-info.ts` because that module
17
- // has top-level Node.js imports (`execFileSync`, `existsSync`) that
18
- // would be dragged into the client bundle the B-11 lesson.
16
+ // import the old `formatDate` from `src/utils/git-info.ts` that module
17
+ // carried top-level Node.js imports (`execFileSync`, `existsSync`) that
18
+ // would be dragged into the client bundle (the B-11 lesson). That file
19
+ // was removed in S1 cleanup (#1928); the mirror below is the canonical copy.
19
20
  //
20
21
  // Labels are resolved from the project's i18n table so non-default
21
22
  // locales (e.g. /ja/) get translated "作成" / "更新" strings.
@@ -36,9 +37,8 @@ import { toHistorySlug } from "@/utils/slug";
36
37
  import docHistoryMeta from "#doc-history-meta";
37
38
 
38
39
  // BCP-47 locale tag mapping used by Intl.DateTimeFormat.
39
- // Kept in sync with `src/utils/git-info.ts` manually; we cannot import
40
- // that module here because it carries top-level Node.js imports
41
- // (`execFileSync`, `existsSync`) — the B-11 lesson applies here too.
40
+ // Originally mirrored from `src/utils/git-info.ts` (removed in S1 #1928).
41
+ // The formatDate function below is the stable copy; kept in sync manually.
42
42
  const LOCALE_TO_BCP47: Record<string, string> = {
43
43
  en: "en-US",
44
44
  ja: "ja-JP",