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
@@ -33,6 +33,17 @@ interface DocContentHeaderProps {
33
33
  * Only relevant for locale-prefixed and versioned-locale routes.
34
34
  */
35
35
  isFallback?: boolean;
36
+ /**
37
+ * Version slug when rendering a versioned route (e.g. "1.0"); undefined =
38
+ * latest. On versioned pages the date block and tag chips are hidden —
39
+ * the doc-history-meta manifest is built only from the LATEST content
40
+ * dirs, so a bare versioned slug would resolve to the latest file's
41
+ * Created/Updated/Author (wrong data), and tag chips would link to latest
42
+ * tag routes that may not exist for version-only tags (404). Mirrors the
43
+ * #1916 reduced-chrome stance that already hides doc history on
44
+ * versioned pages.
45
+ */
46
+ version?: string;
36
47
  }
37
48
 
38
49
  /**
@@ -50,6 +61,7 @@ export function DocContentHeader({
50
61
  slug,
51
62
  locale,
52
63
  isFallback = false,
64
+ version,
53
65
  }: DocContentHeaderProps): JSX.Element {
54
66
  return (
55
67
  <>
@@ -57,11 +69,19 @@ export function DocContentHeader({
57
69
 
58
70
  {/* Build-time date block (Created / Updated / Author).
59
71
  doc-metainfo placement — between <h1> and description.
60
- Data from `.zfb/doc-history-meta.json` (esbuild-inlined, no fs). */}
61
- <DocMetainfoArea slug={slug} locale={locale} />
72
+ Data from `.zfb/doc-history-meta.json` (esbuild-inlined, no fs).
73
+ Hidden on versioned pages — the manifest only covers latest
74
+ content dirs, so a versioned slug would show the LATEST file's
75
+ dates (see the `version` prop doc above). */}
76
+ {!version && (
77
+ <DocMetainfoArea slug={slug} locale={locale} isFallback={isFallback} />
78
+ )}
62
79
 
63
- {/* Page-level tag chips — matching doc-tags placement (#1658). */}
64
- <DocTagsArea slug={slug} locale={locale} tags={entry.data.tags} />
80
+ {/* Page-level tag chips — matching doc-tags placement (#1658).
81
+ Hidden on versioned pages: tag routes are built from latest
82
+ frontmatter only, so a version-only tag chip would 404 (see the
83
+ `version` prop doc above). */}
84
+ {!version && <DocTagsArea slug={slug} locale={locale} tags={entry.data.tags} />}
65
85
 
66
86
  {/* Fallback notice for non-translated pages */}
67
87
  {isFallback && !entry.data.generated && (
@@ -56,7 +56,8 @@ interface DocHistoryAreaProps {
56
56
  /**
57
57
  * Raw zfb entry slug (relative path without extension), e.g.
58
58
  * "getting-started/intro" or "getting-started/index". Appended with
59
- * ".mdx" to form the file path passed to buildGitHubSourceUrl.
59
+ * the source extension from the build-time manifest (".mdx" fallback)
60
+ * to form the file path passed to buildGitHubSourceUrl.
60
61
  * Omit for auto-index pages (no underlying MDX file) — sourceUrl
61
62
  * will be suppressed automatically.
62
63
  */
@@ -130,7 +131,13 @@ export function DocHistoryArea({
130
131
  // locale, "<localeKey>/<slug>" for non-default locales.
131
132
  const composedSlug =
132
133
  effectiveHistoryLocale === defaultLocale ? historySlug : `${effectiveHistoryLocale}/${historySlug}`;
133
- type MetaEntry = { author: string; createdDate: string; updatedDate: string };
134
+ type MetaEntry = {
135
+ author: string;
136
+ createdDate: string;
137
+ updatedDate: string;
138
+ /** Source file extension (".mdx" | ".md") — optional in older manifests. */
139
+ ext?: string;
140
+ };
134
141
  const meta = (docHistoryMeta as Record<string, MetaEntry>)[composedSlug];
135
142
 
136
143
  // Locale-aware labels for the SSR fallback.
@@ -156,7 +163,11 @@ export function DocHistoryArea({
156
163
  const createdDate = meta?.createdDate;
157
164
  const updatedDate = meta?.updatedDate;
158
165
 
159
- const fallback: VNode = (
166
+ // Explicit type annotation omitted: inferred JSX return is structurally
167
+ // compatible with zfb's VNode (the ssrFallback prop target). Preact's
168
+ // VNode<{}> generic form is not directly assignable to zfb's VNode at the
169
+ // type level even though the runtime shapes are identical.
170
+ const fallback = (
160
171
  <div class="sr-only">
161
172
  {author && <span>{author}</span>}
162
173
  <span>
@@ -188,11 +199,16 @@ export function DocHistoryArea({
188
199
  // Compute the view-source GitHub URL host-side so the v2 BodyFootUtilArea
189
200
  // component stays oblivious to project settings. Gate on
190
201
  // bodyFootUtilArea.viewSourceLink, and require both entrySlug and contentDir
191
- // (auto-index pages pass neither).
202
+ // (auto-index pages pass neither). The real source extension comes from the
203
+ // build-time manifest (`ext`, written by pre-build.ts) — the content walkers
204
+ // accept both .mdx and .md, so hardcoding ".mdx" produced broken view-source
205
+ // URLs for .md pages. ".mdx" remains the fallback for entries without a
206
+ // manifest record (untracked files, SKIP_DOC_HISTORY=1, stale manifests).
192
207
  const utilSettings = settings.bodyFootUtilArea;
208
+ const sourceExt = meta?.ext ?? ".mdx";
193
209
  const sourceUrl =
194
210
  utilSettings && utilSettings.viewSourceLink && entrySlug && contentDir
195
- ? buildGitHubSourceUrl(contentDir, entrySlug + ".mdx")
211
+ ? buildGitHubSourceUrl(contentDir, entrySlug + sourceExt)
196
212
  : null;
197
213
 
198
214
  // Resolve the i18n label host-side; pass the result so the v2 component
@@ -61,6 +61,16 @@ interface DocMetainfoAreaProps {
61
61
  slug: string;
62
62
  /** Active locale string, e.g. "en", "ja". */
63
63
  locale: string;
64
+ /**
65
+ * True when this locale page falls back to the base EN collection
66
+ * (i.e. the slug has no translation for the active locale). When true,
67
+ * the manifest lookup uses defaultLocale so the visible block resolves
68
+ * the bare-slug key — the only key that exists for EN-origin files —
69
+ * matching the dropdown's `effectiveHistoryLocale` derivation in
70
+ * _doc-history-area.tsx. Display formatting (dates + labels) still uses
71
+ * the active locale so JA users see JA formatting on fallback pages.
72
+ */
73
+ isFallback?: boolean;
64
74
  }
65
75
 
66
76
  /**
@@ -76,7 +86,7 @@ interface DocMetainfoAreaProps {
76
86
  * HTML from build-time data and has no client JS footprint. It sits
77
87
  * between `<h1>` and the description `<p>` (doc-metainfo placement).
78
88
  */
79
- export function DocMetainfoArea({ slug, locale }: DocMetainfoAreaProps): VNode | null {
89
+ export function DocMetainfoArea({ slug, locale, isFallback }: DocMetainfoAreaProps): VNode | null {
80
90
  if (!settings.docMetainfo) return null;
81
91
 
82
92
  // Doc-history storage sentinel ("" -> "index"): a root index page has the
@@ -87,10 +97,20 @@ export function DocMetainfoArea({ slug, locale }: DocMetainfoAreaProps): VNode |
87
97
  // @/utils/slug `toHistorySlug` and _doc-history-area.tsx. (#1891)
88
98
  const historySlug = toHistorySlug(slug);
89
99
 
100
+ // On EN-fallback locale pages the manifest only has the bare
101
+ // (non-locale-prefixed) key — the prebuild writes locale-prefixed keys
102
+ // only for files physically present in the locale collection. Use
103
+ // defaultLocale for the data lookup when isFallback is true, mirroring
104
+ // `effectiveHistoryLocale` in _doc-history-area.tsx so the visible block
105
+ // and the dropdown agree. Display formatting keeps the active locale.
106
+ const effectiveHistoryLocale = isFallback ? defaultLocale : locale;
107
+
90
108
  // Key format: bare slug for default locale, "<locale>/<slug>" for others.
91
109
  // Matches the prebuild step's composedSlug logic (pre-build.ts).
92
110
  const composedSlug =
93
- locale === defaultLocale ? historySlug : `${locale}/${historySlug}`;
111
+ effectiveHistoryLocale === defaultLocale
112
+ ? historySlug
113
+ : `${effectiveHistoryLocale}/${historySlug}`;
94
114
 
95
115
  type MetaEntry = { author: string; createdDate: string; updatedDate: string };
96
116
  const meta = (docHistoryMeta as Record<string, MetaEntry>)[composedSlug];
@@ -0,0 +1,192 @@
1
+ /** @jsxRuntime automatic */
2
+ /** @jsxImportSource preact */
3
+ // Shared page renderer for the 4 doc catch-all routes.
4
+ //
5
+ // Extracted (#2010) from the near-identical default components of:
6
+ // pages/docs/[[...slug]].tsx
7
+ // pages/[locale]/docs/[[...slug]].tsx
8
+ // pages/v/[version]/docs/[[...slug]].tsx
9
+ // pages/v/[version]/[locale]/docs/[[...slug]].tsx
10
+ //
11
+ // Each route's default export stays a thin adapter that reads its params /
12
+ // route-specific props (locale, version, contentDir, isFallback) and
13
+ // delegates here. Route-specific behavior is parameterized:
14
+ // - `version` present → versioned chrome: versioned canonical URL, version
15
+ // banner, version-aware switcher, auto-index child hrefs kept as the
16
+ // pre-remapped versioned hrefs from paths() (#1916 #2), and doc history
17
+ // hidden until versioned history is supported (#1916 #5).
18
+ // - `version` absent → latest chrome: docsUrl canonical, child hrefs fall
19
+ // back to the nav node's own docsUrl, doc history rendered for listed
20
+ // entries via `docHistoryContentDir`.
21
+
22
+ import { settings } from "@/config/settings";
23
+ import type { VersionConfig } from "@/config/settings";
24
+ import { t, type Locale } from "@/config/i18n";
25
+ import { docsUrl, versionedDocsUrl, absoluteUrl } from "@/utils/base";
26
+ import type { NavNode } from "@/utils/docs";
27
+ import { getNavSectionForSlug } from "@/utils/nav-scope";
28
+ import { toRouteSlug } from "@/utils/slug";
29
+ import type { JSX } from "preact";
30
+ // Shared MDX-tag → Preact-component bag. Includes htmlOverrides
31
+ // (native typography), HtmlPreviewWrapper (Island), and stub bindings
32
+ // for every other custom tag the MDX corpus references — see
33
+ // `pages/_mdx-components.ts` for the full list and rationale.
34
+ import { createMdxComponents } from "../_mdx-components";
35
+ import type { DocPageBaseProps } from "./doc-page-props";
36
+ import { DocHistoryArea } from "./_doc-history-area";
37
+ import { DocMetainfoArea } from "./_doc-metainfo-area";
38
+ import { buildInlineVersionSwitcher } from "./_inline-version-switcher";
39
+ import { DocContentHeader } from "./_doc-content-header";
40
+ import { DocPageShell } from "./_doc-page-shell";
41
+
42
+ export interface RenderDocPageOptions {
43
+ /** Active locale — drives nav wrappers, labels, and URL building. */
44
+ locale: Locale;
45
+ /** Version config when rendering a versioned route; undefined = latest. */
46
+ version?: VersionConfig;
47
+ /** True when this page falls back to the base EN collection (locale
48
+ * routes). Drives the fallback notice + history-area hint. */
49
+ isFallback?: boolean;
50
+ /**
51
+ * Content directory for the doc-history view-source link (e.g. the active
52
+ * locale's dir, or the base docsDir for EN/fallback pages). Latest routes
53
+ * pass it; versioned routes omit it — doc history is hidden on versioned
54
+ * pages regardless (#1916 #5).
55
+ */
56
+ docHistoryContentDir?: string;
57
+ }
58
+
59
+ export function renderDocPage(
60
+ props: DocPageBaseProps,
61
+ opts: RenderDocPageOptions,
62
+ ): JSX.Element {
63
+ const { breadcrumbs, prev, next, headings } = props;
64
+ const { locale, version, isFallback } = opts;
65
+
66
+ const slug = props.kind === "autoIndex"
67
+ ? props.autoIndex.slug
68
+ : (props.entry.data.slug ?? toRouteSlug(props.entry.slug));
69
+
70
+ const title = props.kind === "autoIndex" ? props.autoIndex.label : props.entry.data.title;
71
+ const description = props.kind === "autoIndex" ? props.autoIndex.description : props.entry.data.description;
72
+
73
+ // Locale-aware components bag — creates nav wrappers bound to the active
74
+ // locale so CategoryNav/CategoryTreeNav/SiteTreeNav query the right collection.
75
+ const components = createMdxComponents(locale);
76
+
77
+ // Resolve child hrefs for auto-index pages. Versioned routes: child cards
78
+ // already carry versioned hrefs from paths() (#1916 #2) — just filter to
79
+ // renderable nodes. Latest routes: keep the nav node's own docsUrl href
80
+ // (fallback for a noPage parent without an href).
81
+ const autoIndexChildren = props.kind === "autoIndex"
82
+ ? version
83
+ ? props.autoIndex.children.filter((c: NavNode) => c.hasPage || c.children.length > 0)
84
+ : props.autoIndex.children
85
+ .filter((c: NavNode) => c.hasPage || c.children.length > 0)
86
+ .map((c: NavNode) => ({
87
+ ...c,
88
+ href: c.href ?? docsUrl(c.slug, locale),
89
+ }))
90
+ : [];
91
+
92
+ // Version banner: drives the `<VersionBanner>` element inside
93
+ // DocLayoutWithDefaults when `version.banner` is "unmaintained" or
94
+ // "unreleased". The banner links out to the latest version of the
95
+ // current page (slug-preserving — strips the /v/{version}/ prefix,
96
+ // keeps the /{locale}/ locale prefix).
97
+ const versionBannerType = version?.banner ? version.banner : undefined;
98
+ const versionBannerLatestUrl = versionBannerType
99
+ ? docsUrl(slug, locale)
100
+ : undefined;
101
+ const versionBannerLabels = versionBannerType
102
+ ? {
103
+ message:
104
+ versionBannerType === "unmaintained"
105
+ ? t("version.banner.unmaintained", locale)
106
+ : t("version.banner.unreleased", locale),
107
+ latestLink: t("version.banner.latestLink", locale),
108
+ }
109
+ : undefined;
110
+
111
+ // Canonical URL — base-prefixed page path, absolutized against siteUrl.
112
+ // Versioned pages use the versioned URL as canonical.
113
+ const currentPath = version
114
+ ? versionedDocsUrl(slug, version.slug, locale)
115
+ : docsUrl(slug, locale);
116
+ const canonical = absoluteUrl(currentPath);
117
+
118
+ // Persist key: locale + nav-section so the sidebar DOM node is reused
119
+ // across same-locale + same-section navigations only. No sanitizer needed —
120
+ // both lang (BCP-47 locale string) and navSection (filesystem-derived
121
+ // kebab-case slug) come from controlled, trusted sources.
122
+ const navSection = getNavSectionForSlug(slug);
123
+ const hideSidebar = props.kind === "entry" ? props.entry.data.hide_sidebar : undefined;
124
+ const sidebarPersistKey = hideSidebar
125
+ ? undefined
126
+ : `sidebar-${locale}-${navSection ?? "default"}`;
127
+
128
+ return (
129
+ <DocPageShell
130
+ kind={props.kind}
131
+ locale={locale}
132
+ slug={slug}
133
+ title={title}
134
+ description={description}
135
+ canonical={canonical}
136
+ breadcrumbs={breadcrumbs}
137
+ prev={prev}
138
+ next={next}
139
+ headings={headings}
140
+ navSection={navSection}
141
+ sidebarPersistKey={sidebarPersistKey}
142
+ hideSidebar={hideSidebar}
143
+ hideToc={props.kind === "entry" ? props.entry.data.hide_toc : undefined}
144
+ currentPath={currentPath}
145
+ currentVersion={version?.slug}
146
+ versionSwitcher={buildInlineVersionSwitcher(slug, locale, version?.slug)}
147
+ versionBanner={versionBannerType}
148
+ versionBannerLatestUrl={versionBannerLatestUrl}
149
+ versionBannerLabels={versionBannerLabels}
150
+ autoIndexLabel={props.kind === "autoIndex" ? props.autoIndex.label : undefined}
151
+ autoIndexChildren={autoIndexChildren}
152
+ metainfoSlot={
153
+ // Versioned gate mirrors DocContentHeader: the doc-history-meta
154
+ // manifest is built from latest dirs only, so a bare versioned slug
155
+ // would surface the LATEST page's Created/Updated/Author.
156
+ !version && props.kind === "autoIndex" ? (
157
+ <DocMetainfoArea slug={slug} locale={locale} isFallback={isFallback} />
158
+ ) : null
159
+ }
160
+ contentHeaderSlot={
161
+ props.kind === "entry" ? (
162
+ <DocContentHeader
163
+ entry={props.entry}
164
+ slug={slug}
165
+ locale={locale}
166
+ isFallback={isFallback}
167
+ version={version?.slug}
168
+ />
169
+ ) : undefined
170
+ }
171
+ contentSlot={
172
+ props.kind === "entry" ? <props.entry.Content components={components} /> : undefined
173
+ }
174
+ docHistorySlot={
175
+ // #1916 #5: doc-history hidden on versioned pages until versioned
176
+ // history is supported.
177
+ !version &&
178
+ opts.docHistoryContentDir !== undefined &&
179
+ props.kind === "entry" &&
180
+ !props.entry.data.unlisted ? (
181
+ <DocHistoryArea
182
+ slug={slug}
183
+ locale={locale}
184
+ entrySlug={props.entry.slug}
185
+ contentDir={opts.docHistoryContentDir}
186
+ isFallback={isFallback}
187
+ />
188
+ ) : null
189
+ }
190
+ />
191
+ );
192
+ }
@@ -32,6 +32,7 @@ import { DocBodyEnd } from "./_doc-body-end";
32
32
  import { DocPager } from "./_doc-pager";
33
33
  import type { BreadcrumbItem } from "@/utils/docs";
34
34
  import type { VersionBannerLabels } from "@takazudo/zudo-doc/i18n-version";
35
+ import type { Locale } from "@/config/i18n";
35
36
  import type { extractHeadings } from "./_extract-headings";
36
37
 
37
38
  /** Slots and parameters that vary between the 4 doc routes. */
@@ -161,7 +162,7 @@ export function DocPageShell(props: DocPageShellProps): JSX.Element {
161
162
  versionBannerLabels={versionBannerLabels}
162
163
  headerOverride={
163
164
  <HeaderWithDefaults
164
- lang={locale}
165
+ lang={locale as Locale}
165
166
  currentSlug={props.slug}
166
167
  navSection={navSection}
167
168
  currentVersion={currentVersion}
@@ -176,7 +177,7 @@ export function DocPageShell(props: DocPageShellProps): JSX.Element {
176
177
  sidebarOverride={
177
178
  <SidebarWithDefaults
178
179
  currentSlug={props.slug}
179
- lang={locale}
180
+ lang={locale as Locale}
180
181
  navSection={navSection}
181
182
  currentVersion={currentVersion}
182
183
  currentPath={currentPath}
@@ -0,0 +1,188 @@
1
+ // Shared, memoized route-entry builder for the 4 doc catch-all routes.
2
+ //
3
+ // Extracted (#2010) from the ~85%-duplicated paths() bodies of:
4
+ // pages/docs/[[...slug]].tsx
5
+ // pages/[locale]/docs/[[...slug]].tsx
6
+ // pages/v/[version]/docs/[[...slug]].tsx
7
+ // pages/v/[version]/[locale]/docs/[[...slug]].tsx
8
+ //
9
+ // Each route resolves its own identity-stable nav source (resolveNavSource /
10
+ // resolveVersionedLocaleSource) and URL closure, then delegates the per-entry
11
+ // derived-data work here. The result is memoized with the #1902 WeakMap
12
+ // pattern (memoizeDerived keyed on the identity-stable `source.docs` array +
13
+ // a per-route signature), so the expensive per-entry work — extractHeadings,
14
+ // buildBreadcrumbs, prev/next resolution — runs ONCE per entry per build,
15
+ // not once per entry per page (zfb re-invokes paths() once per built page).
16
+ //
17
+ // Versioned-vs-latest behavior is keyed on the presence of `urlFor` (#1916):
18
+ // - `urlFor` set (versioned routes): breadcrumbs resolve against the NAV
19
+ // tree (unlisted excluded) with crumbs remapped to the versioned URL
20
+ // space; prev/next hrefs are rewritten through the closure; auto-index
21
+ // child-card hrefs are ALWAYS remapped to the versioned URL.
22
+ // - `urlFor` unset (latest routes): breadcrumbs resolve against the FULL
23
+ // tree (unlisted included, for accurate crumbs); prev/next and child
24
+ // hrefs keep the latest `docsUrl` already baked into the nav nodes.
25
+ // These two behaviors travel together by construction: only versioned routes
26
+ // own a versioned URL closure (see _doc-route-paths.ts for the #1916
27
+ // rationale on why latest routes must never receive one).
28
+
29
+ import {
30
+ buildNavTree,
31
+ buildBreadcrumbs,
32
+ collectAutoIndexNodes,
33
+ type NavNode,
34
+ } from "@/utils/docs";
35
+ import { getNavSectionForSlug, getNavSubtree } from "@/utils/nav-scope";
36
+ import { toRouteSlug, toSlugParams } from "@/utils/slug";
37
+ import type { Locale } from "@/config/i18n";
38
+ import { extractHeadings } from "./_extract-headings";
39
+ import type { AutoIndexNode, DocPageBaseProps } from "./doc-page-props";
40
+ import { memoizeDerived } from "./_nav-source-cache";
41
+ import type { NavSourceDocs } from "./_nav-source-docs";
42
+ import {
43
+ resolveDocPrevNext,
44
+ flattenSubtree,
45
+ rewriteNavHref,
46
+ remapNavChildHrefs,
47
+ } from "./_doc-route-paths";
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Types
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** One enumerated doc route: a content entry or an auto-generated category
54
+ * index, with all per-page derived data pre-computed. */
55
+ export interface DocRouteEntry {
56
+ /** Canonical route slug ("" for the docs root index — #1891). */
57
+ slug: string;
58
+ /** Optional-catchall params array — `toSlugParams(slug)` ([] for the root). */
59
+ slugParams: string[];
60
+ /**
61
+ * True when the entry came from the base collection rather than the locale
62
+ * collection (`!localeSlugSet.has(slug)`). Only meaningful on routes whose
63
+ * nav source performs a locale merge — routes without one (default-locale /
64
+ * versioned-EN, where `localeSlugSet` is empty) must ignore this field.
65
+ * Always false for autoIndex items.
66
+ */
67
+ isFallback: boolean;
68
+ /** Shared page props (kind/entry/autoIndex/breadcrumbs/prev/next/headings). */
69
+ props: DocPageBaseProps;
70
+ }
71
+
72
+ export interface BuildDocRouteEntriesArgs {
73
+ /** Identity-stable nav source for this route's (locale, version) context —
74
+ * from resolveNavSource / resolveVersionedLocaleSource. The memo is keyed
75
+ * on `source.docs` identity, so the source MUST come from those resolvers
76
+ * (a fresh array defeats the memo — harmless, but recomputes per call). */
77
+ source: NavSourceDocs;
78
+ /** Active locale for nav-tree labels and breadcrumbs. */
79
+ locale: Locale;
80
+ /**
81
+ * Unique memo signature for this route context. Each route file passes its
82
+ * own prefix plus the loop variables (version slug / locale), e.g.
83
+ * "docs;en", "locale-docs;ja", "v-docs;1.0", "v-locale-docs;1.0;ja" —
84
+ * call sites that share a docs array identity must never collide on a key.
85
+ */
86
+ routeSig: string;
87
+ /** Versioned URL closure bound to the route's version (+ locale). Presence
88
+ * switches the versioned behaviors documented in the module header. */
89
+ urlFor?: (slug: string) => string;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // buildDocRouteEntries
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Enumerate all doc routes (content entries + auto-index pages) for one
98
+ * (locale, version) context, with per-entry derived data pre-computed.
99
+ *
100
+ * Memoized per build on the identity-stable `source.docs` array (#1902), so
101
+ * repeated paths() invocations across the route's many pages return the SAME
102
+ * array instance without redoing the per-entry work. In the no-snapshot
103
+ * fallback path (unit tests / direct Node runs) `source.docs` is a fresh
104
+ * array per call, so the memo misses and this computes fresh — matching the
105
+ * deliberate no-memo policy in _nav-source-cache.ts.
106
+ */
107
+ export function buildDocRouteEntries(
108
+ args: BuildDocRouteEntriesArgs,
109
+ ): DocRouteEntry[] {
110
+ const { source, locale, routeSig, urlFor } = args;
111
+
112
+ return memoizeDerived([source.docs], `docRouteEntries;${routeSig}`, () => {
113
+ const { docs, navDocs, categoryMeta, localeSlugSet } = source;
114
+
115
+ // Nav docs: exclude unlisted (for sidebar/prev-next) but keep for breadcrumbs
116
+ const tree = buildNavTree(navDocs, locale, categoryMeta);
117
+ // Breadcrumb tree: latest routes use the full tree (including unlisted)
118
+ // for accurate crumbs; versioned routes resolve crumbs against the nav
119
+ // tree itself (#1916 #1).
120
+ const breadcrumbTree = urlFor ? tree : buildNavTree(docs, locale, categoryMeta);
121
+
122
+ const result: DocRouteEntry[] = [];
123
+
124
+ // Regular doc pages
125
+ for (const entry of docs) {
126
+ // A `category_no_page` index.mdx carries category metadata only — keep
127
+ // it in the nav tree (built above, used for breadcrumbs) but emit NO
128
+ // route for it. zfb's walker retains every .mdx as a collection entry,
129
+ // so without this explicit skip the metadata file would silently add a
130
+ // route.
131
+ if (entry.data.category_no_page === true) continue;
132
+ // Canonical route slug via the one shared rule (@/utils/slug) — yields
133
+ // "" for a root index (URL /docs/ — #1891).
134
+ const slug = entry.data.slug ?? toRouteSlug(entry.slug);
135
+ const navSection = getNavSectionForSlug(slug);
136
+ const subtree = getNavSubtree(tree, navSection);
137
+
138
+ // Prev/next + frontmatter pagination overrides resolved against THIS
139
+ // route's own `tree`; versioned routes then rewrite the hrefs through
140
+ // their urlFor closure (latest routes pass it through unchanged).
141
+ const { prev: prevNode, next: nextNode } = resolveDocPrevNext(
142
+ tree,
143
+ flattenSubtree(subtree),
144
+ slug,
145
+ entry.data,
146
+ );
147
+
148
+ result.push({
149
+ slug,
150
+ slugParams: toSlugParams(slug),
151
+ isFallback: !localeSlugSet.has(slug),
152
+ props: {
153
+ kind: "entry",
154
+ entry,
155
+ breadcrumbs: buildBreadcrumbs(breadcrumbTree, slug, locale, urlFor),
156
+ prev: rewriteNavHref(prevNode, urlFor),
157
+ next: rewriteNavHref(nextNode, urlFor),
158
+ headings: extractHeadings(entry.body ?? ""),
159
+ },
160
+ });
161
+ }
162
+
163
+ // Auto-generated index pages for categories without index.mdx
164
+ for (const node of collectAutoIndexNodes(tree)) {
165
+ result.push({
166
+ slug: node.slug,
167
+ slugParams: toSlugParams(node.slug),
168
+ isFallback: false,
169
+ props: {
170
+ kind: "autoIndex",
171
+ autoIndex: urlFor
172
+ ? // #1916 #2: child-card hrefs ALWAYS resolve to the versioned URL.
173
+ ({
174
+ ...node,
175
+ children: remapNavChildHrefs(node.children, urlFor) as NavNode[],
176
+ } as AutoIndexNode)
177
+ : (node as AutoIndexNode),
178
+ breadcrumbs: buildBreadcrumbs(breadcrumbTree, node.slug, locale, urlFor),
179
+ prev: null,
180
+ next: null,
181
+ headings: [],
182
+ },
183
+ });
184
+ }
185
+
186
+ return result;
187
+ });
188
+ }
@@ -34,12 +34,17 @@ import { DocTags } from "@takazudo/zudo-doc/metainfo";
34
34
  *
35
35
  * Inlined from _footer-with-defaults.tsx `tagHref` — not extracted to avoid
36
36
  * ripple (spec rule: no opportunistic refactor on tagHref extraction).
37
+ *
38
+ * The tag segment is URL-encoded at the href site only — route params stay
39
+ * raw, so the built output dir keeps the raw tag name and the server decodes
40
+ * the percent-encoded href back to it (e.g. "type:guide" → "type%3Aguide").
37
41
  */
38
42
  function tagHref(tag: string, locale: string): string {
43
+ const encoded = encodeURIComponent(tag);
39
44
  const path =
40
45
  locale === defaultLocale
41
- ? `/docs/tags/${tag}`
42
- : `/${locale}/docs/tags/${tag}`;
46
+ ? `/docs/tags/${encoded}`
47
+ : `/${locale}/docs/tags/${encoded}`;
43
48
  return withBase(path);
44
49
  }
45
50