create-zudo-doc 0.2.0 → 0.2.2

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 (83) hide show
  1. package/dist/api.js +4 -1
  2. package/dist/cli.js +4 -6
  3. package/dist/compose.d.ts +2 -3
  4. package/dist/compose.js +7 -4
  5. package/dist/features/tauri.d.ts +10 -5
  6. package/dist/features/tauri.js +49 -6
  7. package/dist/preset.js +11 -0
  8. package/dist/prompts.js +2 -6
  9. package/dist/scaffold.js +15 -9
  10. package/dist/settings-gen.js +9 -6
  11. package/dist/utils.d.ts +8 -0
  12. package/dist/utils.js +25 -0
  13. package/dist/zfb-config-gen.js +11 -50
  14. package/package.json +1 -1
  15. package/templates/base/pages/_data.ts +10 -23
  16. package/templates/base/pages/docs/[[...slug]].tsx +27 -168
  17. package/templates/base/pages/lib/_body-end-islands.tsx +3 -0
  18. package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
  19. package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
  20. package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
  21. package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
  22. package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
  23. package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
  24. package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
  25. package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
  26. package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
  27. package/templates/base/pages/lib/_header-with-defaults.tsx +54 -89
  28. package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
  29. package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
  30. package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
  31. package/templates/base/pages/lib/_search-widget-script.ts +32 -9
  32. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
  33. package/templates/base/pages/lib/locale-merge.ts +1 -1
  34. package/templates/base/pages/lib/route-enumerators.ts +11 -7
  35. package/templates/base/plugins/connect-adapter.mjs +30 -1
  36. package/templates/base/plugins/copy-public-plugin.mjs +10 -2
  37. package/templates/base/plugins/search-index-plugin.mjs +20 -8
  38. package/templates/base/src/components/ai-chat-modal.tsx +2 -0
  39. package/templates/base/src/components/doc-history.tsx +2 -0
  40. package/templates/base/src/components/image-enlarge.tsx +2 -0
  41. package/templates/base/src/components/sidebar-toggle.tsx +1 -1
  42. package/templates/base/src/components/sidebar-tree.tsx +11 -5
  43. package/templates/base/src/components/theme-toggle.tsx +18 -102
  44. package/templates/base/src/config/color-schemes.ts +4 -0
  45. package/templates/base/src/config/docs-schema.ts +94 -0
  46. package/templates/base/src/config/i18n.ts +10 -3
  47. package/templates/base/src/styles/global.css +14 -0
  48. package/templates/base/src/types/docs-entry.ts +8 -26
  49. package/templates/base/src/utils/base.ts +5 -3
  50. package/templates/base/src/utils/docs.ts +144 -169
  51. package/templates/base/zfb-shim.d.ts +167 -0
  52. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
  53. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
  54. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
  55. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
  56. package/templates/features/docHistory/files/src/components/doc-history.tsx +30 -8
  57. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
  58. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
  59. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
  60. package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
  61. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
  62. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
  63. package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
  64. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +2 -0
  65. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
  66. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
  67. package/templates/features/tauri/files/src/components/find-in-page-init.tsx +9 -3
  68. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
  69. package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
  70. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
  71. package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
  72. package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
  73. package/templates/base/src/components/content/heading-h3.tsx +0 -20
  74. package/templates/base/src/hooks/use-active-heading.ts +0 -133
  75. package/templates/base/src/plugins/docs-source-map.ts +0 -103
  76. package/templates/base/src/plugins/hast-utils.ts +0 -10
  77. package/templates/base/src/plugins/rehype-code-title.ts +0 -50
  78. package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
  79. package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
  80. package/templates/base/src/plugins/url-utils.ts +0 -4
  81. package/templates/base/src/utils/dedent.ts +0 -24
  82. package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
  83. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +0 -198
@@ -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
 
@@ -30,8 +30,8 @@ import { defaultLocale } from "@/config/i18n";
30
30
  import { tagVocabulary } from "@/config/tag-vocabulary";
31
31
  import { collectTags } from "@/utils/tags";
32
32
  import { toRouteSlug } from "@/utils/slug";
33
- import { loadDocs } from "../_data";
34
33
  import { mergeLocaleDocs } from "./locale-merge";
34
+ import { stableDocs, memoizeDerived } from "./_nav-source-cache";
35
35
  import type { DocsEntry } from "@/types/docs-entry";
36
36
 
37
37
  // ---------------------------------------------------------------------------
@@ -51,12 +51,18 @@ function localizeHref(href: string, lang: string): string {
51
51
  return resolveHref(href);
52
52
  }
53
53
 
54
- /** Build the base-prefixed tag detail page href for the given locale. */
54
+ /**
55
+ * Build the base-prefixed tag detail page href for the given locale.
56
+ * The tag segment is URL-encoded at the href site only — route params stay
57
+ * raw, so the built output dir keeps the raw tag name and the server decodes
58
+ * the percent-encoded href back to it (e.g. "type:guide" → "type%3Aguide").
59
+ */
55
60
  function tagHref(tag: string, lang: string): string {
61
+ const encoded = encodeURIComponent(tag);
56
62
  const path =
57
63
  lang === defaultLocale
58
- ? `/docs/tags/${tag}`
59
- : `/${lang}/docs/tags/${tag}`;
64
+ ? `/docs/tags/${encoded}`
65
+ : `/${lang}/docs/tags/${encoded}`;
60
66
  return withBase(path);
61
67
  }
62
68
 
@@ -110,15 +116,22 @@ export function FooterWithDefaults({
110
116
  let tagColumns: FooterTagColumn[] = [];
111
117
 
112
118
  if (taglist?.enabled) {
113
- // Load docs synchronously (zfb ADR-004 synchronous content snapshot).
114
- let docs: DocsEntry[];
115
- if (lang === defaultLocale) {
116
- // category_no_page index files build no route — drop them so the footer
117
- // taglist matches the tag-route pages (which all now filter too).
118
- docs = loadDocs("docs").filter(
119
- (d) => !d.data.draft && !d.data.unlisted && !d.data.category_no_page,
120
- );
121
- } else {
119
+ // Memoize docs loading and tag aggregation on the snapshot-anchored stable
120
+ // docs identity — same pattern as _nav-source-cache — so this block runs
121
+ // once per locale per build, not once per page render.
122
+ const allTags = (() => {
123
+ if (lang === defaultLocale) {
124
+ const baseDocs = stableDocs("docs");
125
+ return memoizeDerived([baseDocs], "footerTaglist;default", () => {
126
+ // category_no_page index files build no route — drop them so the
127
+ // footer taglist matches the tag-route pages (which all now filter too).
128
+ const docs: DocsEntry[] = baseDocs.filter(
129
+ (d) => !d.data.draft && !d.data.unlisted && !d.data.category_no_page,
130
+ );
131
+ const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
132
+ return [...tagMap.values()].sort((a, b) => a.tag.localeCompare(b.tag, lang));
133
+ });
134
+ }
122
135
  // Apply the default-locale-only filter so the footer taglist only counts
123
136
  // tags that have a locale-routable tag page — matching the tag-route
124
137
  // pages ([tag].tsx / tags/index.tsx) and enumerateTagsRoutes, which all
@@ -127,21 +140,19 @@ export function FooterWithDefaults({
127
140
  // prefix pages. category_no_page is dropped for the same reason — AFTER
128
141
  // the merge, so a locale override carrying the flag first wins the merge
129
142
  // (pre-merge filtering would let the unflagged base doc resurface).
130
- const result = mergeLocaleDocs({
131
- baseDocs: loadDocs("docs").filter((d) => !d.data.draft),
132
- localeDocs: loadDocs(`docs-${lang}`).filter((d) => !d.data.draft),
133
- applyDefaultLocaleOnlyFilter: true,
143
+ const baseDocs = stableDocs("docs");
144
+ const localeDocs = stableDocs(`docs-${lang}`);
145
+ return memoizeDerived([baseDocs, localeDocs], `footerTaglist;${lang}`, () => {
146
+ const result = mergeLocaleDocs({
147
+ baseDocs: baseDocs.filter((d) => !d.data.draft),
148
+ localeDocs: localeDocs.filter((d) => !d.data.draft),
149
+ applyDefaultLocaleOnlyFilter: true,
150
+ });
151
+ const docs: DocsEntry[] = result.docs.filter((d) => !d.data.category_no_page);
152
+ const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
153
+ return [...tagMap.values()].sort((a, b) => a.tag.localeCompare(b.tag, lang));
134
154
  });
135
- docs = result.docs.filter((d) => !d.data.category_no_page);
136
- }
137
-
138
- const tagMap = collectTags(
139
- docs,
140
- (id, data) => data.slug ?? toRouteSlug(id),
141
- );
142
- const allTags = [...tagMap.values()].sort((a, b) =>
143
- a.tag.localeCompare(b.tag, lang),
144
- );
155
+ })();
145
156
 
146
157
  const vocabularyActive =
147
158
  Boolean(settings.tagVocabulary) && settings.tagGovernance !== "off";
@@ -20,16 +20,13 @@
20
20
 
21
21
  import type { JSX } from "preact";
22
22
  import { OgTags, TwitterCard } from "@takazudo/zudo-doc/head";
23
- // Inlined from @takazudo/zudo-doc sidebar-resizer-init.tsx (SIDEBAR_RESIZER_RESTORE_SCRIPT) — the published 0.1.0 dist doesn't export it yet; keep in sync if the clamp bounds [192,448] change.
24
- const SIDEBAR_RESIZER_RESTORE_SCRIPT = `(function(){try{var w=localStorage.getItem("zudo-doc-sidebar-width");if(!w)return;var n=parseFloat(w);if(!isFinite(n))return;if(n<192)n=192;else if(n>448)n=448;document.documentElement.style.setProperty("--zd-sidebar-w",n+"px");}catch(e){}})();`;
25
- // Don't import ColorSchemeProvider from "@takazudo/zudo-doc/theme" — that
26
- // barrel also re-exports DesignTokenTweakPanel + ColorTweakExportModal, which
27
- // transitively pull `src/components/design-token-tweak/*` and the v2 panel
28
- // modules (and react-dependent code) into the zfb esbuild graph. Same hazard
29
- // the host's `_header-with-defaults.tsx` documents for ThemeToggle. The v2
30
- // package exposes a dedicated `./theme/color-scheme-provider` subpath whose
31
- // only output is the SSR-only ColorSchemeProvider component, keeping this
32
- // head emission free of the panel-module dependency chain.
23
+ import { SIDEBAR_RESIZER_RESTORE_SCRIPT } from "@takazudo/zudo-doc/sidebar-resizer";
24
+ // Import ColorSchemeProvider from the dedicated
25
+ // `./theme/color-scheme-provider` subpath rather than the
26
+ // "@takazudo/zudo-doc/theme" barrel — the barrel also re-exports the
27
+ // ColorTweakExportModal island and the design-token SerDe/iframe-bridge
28
+ // modules, which this SSR-only head emission does not need in its zfb
29
+ // esbuild graph.
33
30
  import ColorSchemeProvider from "@takazudo/zudo-doc/theme/color-scheme-provider";
34
31
  import { composeMetaTitle } from "./_compose-meta-title";
35
32
  import { withBase, absoluteUrl } from "@/utils/base";
@@ -25,8 +25,8 @@
25
25
  // hides it on pages with hide_sidebar). When `navSection` is defined the
26
26
  // panel gets the full section tree; when undefined (home, 404, tags,
27
27
  // versions) nodes=[] so the panel shows only rootMenuItems.
28
- // - ThemeToggle from the package (self-island-wrapped) is always passed to
29
- // Header.themeToggle so the ThemeToggle island marker appears in the
28
+ // - The bare package ThemeToggle (wrapped in Island below) is always passed
29
+ // to Header.themeToggle so the ThemeToggle island marker appears in the
30
30
  // header on every page — matching the documented header contract.
31
31
  //
32
32
  // Locale switcher strategy (refs #1453):
@@ -45,68 +45,33 @@ import {
45
45
  VersionSwitcher,
46
46
  type VersionSwitcherLabels,
47
47
  } from "@takazudo/zudo-doc/i18n-version";
48
- // Don't import ThemeToggle from "@takazudo/zudo-doc/theme" — that barrel
49
- // also re-exports DesignTokenTweakPanel and ColorTweakExportModal, which
50
- // transitively pull `src/components/design-token-tweak/*` and the v2 panel
51
- // modules into the zfb esbuild graph. Those files import `react`, which
52
- // zfb does not alias to `preact/compat`, so the build fails. Use the host's
53
- // local ThemeToggle (already on `preact/hooks`) and wrap it in Island here
54
- // so the SSG output still emits the `data-zfb-island="ThemeToggle"` marker.
55
- import ThemeToggle from "@/components/theme-toggle";
48
+ // ThemeToggle via the project-source shim (`@/components/theme-toggle`)
49
+ // rather than the package subpath directly. zfb's island scanner does not
50
+ // register "use client" modules under node_modules (zfb#999), so importing
51
+ // the package component here would emit an island marker with no registry
52
+ // entry and the toggle would never hydrate (zudolab/zudo-doc#2048). The shim
53
+ // re-wraps the bare (non-island-wrapped) package ThemeToggle; this wrapper
54
+ // composes its own Island below, avoiding nesting an island inside an island.
55
+ import { ThemeToggle } from "@/components/theme-toggle";
56
56
  import SidebarToggle from "@/components/sidebar-toggle";
57
57
  import { settings } from "@/config/settings";
58
58
  import { defaultLocale, locales, t, type Locale } from "@/config/i18n";
59
59
  import { buildGitHubRepoUrl } from "@/utils/github";
60
60
  import {
61
- buildLocaleLinks,
62
61
  docsUrl,
63
62
  navHref,
64
63
  stripBase,
65
64
  versionedDocsUrl,
66
65
  withBase,
67
66
  } from "@/utils/base";
68
- import {
69
- type NavNode,
70
- } from "@/utils/docs";
71
- import { buildSidebarForSection } from "@/utils/sidebar";
72
67
  import { filterHeaderRightItems } from "@takazudo/zudo-doc/header";
73
68
  import { SearchWidget } from "./_search-widget";
74
- import { loadNavSourceDocs } from "./_nav-source-docs";
75
-
76
- // ---------------------------------------------------------------------------
77
- // Internal helpers
78
- // ---------------------------------------------------------------------------
79
-
80
- /**
81
- * Walk the nav tree and rewrite each node's `href` to its versioned form.
82
- *
83
- * `buildNavTree` always emits hrefs via `docsUrl()`; when the active route
84
- * lives under `/v/{version}/...` we need the same nodes pointing at the
85
- * versioned URL so internal nav clicks stay inside the version. Skips
86
- * nodes without an href (link-only or category placeholders).
87
- *
88
- * Intentionally kept as a local copy in this module (not extracted) —
89
- * T2 only dedupes loadNavSourceDocs; remapVersionedHrefs is out of scope.
90
- */
91
- function remapVersionedHrefs(
92
- nodes: NavNode[],
93
- version: string,
94
- nodeLang: Locale,
95
- ): NavNode[] {
96
- return nodes.map((node) => {
97
- const children =
98
- node.children.length > 0
99
- ? remapVersionedHrefs(node.children, version, nodeLang)
100
- : node.children;
101
-
102
- if (!node.href || node.slug.startsWith("__link__")) {
103
- return children !== node.children ? { ...node, children } : node;
104
- }
105
-
106
- const newHref = versionedDocsUrl(node.slug, version, nodeLang);
107
- return { ...node, href: newHref, children };
108
- });
109
- }
69
+ import {
70
+ buildRootMenuItems,
71
+ buildLocaleLinksForNav,
72
+ buildSidebarNodes,
73
+ getThemeDefaultMode,
74
+ } from "./_nav-data-prep";
110
75
 
111
76
  // ---------------------------------------------------------------------------
112
77
  // Component
@@ -114,7 +79,7 @@ function remapVersionedHrefs(
114
79
 
115
80
  export interface HeaderWithDefaultsProps {
116
81
  /** Active locale; defaults to the configured defaultLocale. */
117
- lang?: Locale;
82
+ lang?: Locale | string;
118
83
  /**
119
84
  * Current page URL path (as the layout passes from Astro.url.pathname or
120
85
  * the zfb equivalent). Used by the Header to compute the active nav item
@@ -151,27 +116,21 @@ export function HeaderWithDefaults(
151
116
  props: HeaderWithDefaultsProps,
152
117
  ): JSX.Element {
153
118
  const {
154
- lang = defaultLocale,
119
+ lang: langProp = defaultLocale,
155
120
  currentPath = "",
156
121
  currentVersion,
157
122
  currentSlug,
158
123
  navSection,
159
124
  } = props;
125
+ // Route params arrive as `string`; cast to Locale since keys of settings.locales
126
+ // are always valid locale codes. The prop accepts `Locale | string` so callers
127
+ // without a Locale variable don't need to cast (e.g. _tag-pages.tsx).
128
+ const lang = langProp as Locale;
160
129
 
161
- // Root-menu items for the mobile sidebar's "back to menu" list.
162
- // Mirrors the data-prep in _sidebar-with-defaults.tsx.
163
- const rootMenuItems = settings.headerNav.map((item) => ({
164
- label: item.labelKey
165
- ? t(item.labelKey as Parameters<typeof t>[0], lang)
166
- : item.label,
167
- href: navHref(item.path, lang, currentVersion),
168
- children: item.children?.map((child) => ({
169
- label: child.labelKey
170
- ? t(child.labelKey as Parameters<typeof t>[0], lang)
171
- : child.label,
172
- href: navHref(child.path, lang, currentVersion),
173
- })),
174
- }));
130
+ // Root-menu items, locale links, sidebar nodes, and theme mode — all
131
+ // delegated to the shared _nav-data-prep helpers so header and sidebar
132
+ // wrappers stay in sync without duplicating the logic.
133
+ const rootMenuItems = buildRootMenuItems(lang, currentVersion);
175
134
 
176
135
  // Build the mobile sidebar toggle unconditionally — SidebarToggle is rendered
177
136
  // on every page (refs #1453); the host CSS hides it where unneeded. When navSection is
@@ -182,21 +141,11 @@ export function HeaderWithDefaults(
182
141
 
183
142
  // Locale-switcher links in the mobile sidebar footer — only when
184
143
  // multiple locales are configured (mirrors _sidebar-with-defaults.tsx).
185
- const localeLinks =
186
- locales.length > 1 ? buildLocaleLinks(currentPath, lang) : undefined;
144
+ const localeLinks = buildLocaleLinksForNav(currentPath, lang, locales.length);
187
145
 
188
- const themeDefaultMode = settings.colorMode
189
- ? settings.colorMode.defaultMode
190
- : undefined;
146
+ const themeDefaultMode = getThemeDefaultMode();
191
147
 
192
- let sidebarNodes: NavNode[] = [];
193
- if (navSection !== undefined) {
194
- const { navDocs, categoryMeta } = loadNavSourceDocs(lang, currentVersion);
195
- const rawNodes = buildSidebarForSection(navDocs, lang, navSection, categoryMeta);
196
- sidebarNodes = currentVersion
197
- ? remapVersionedHrefs(rawNodes, currentVersion, lang)
198
- : rawNodes;
199
- }
148
+ const sidebarNodes = buildSidebarNodes(lang, navSection, currentVersion);
200
149
 
201
150
  // Wrap SidebarToggle (hamburger button + slide-in aside + SidebarTree) in
202
151
  // Island so the SSG output carries the full tree HTML AND the
@@ -211,8 +160,25 @@ export function HeaderWithDefaults(
211
160
  // nested as a JSX child its data was dropped during hydration and
212
161
  // SidebarToggle re-rendered with `children=undefined`, wiping the SSR
213
162
  // tree DOM. zudolab/zudo-doc#1355 wave 13.5.
163
+ //
164
+ // C4 — media-gated hydration. zfb only supports load|idle|visible
165
+ // strategies (no "media" strategy; matchMedia inside the component is too
166
+ // late — props are already emitted, bundle already downloaded).
167
+ // Upstream feature request: Takazudo/zudo-front-builder#969.
168
+ //
169
+ // Best achievable downstream: when="visible" + all SidebarToggle children
170
+ // are lg:hidden, so on desktop the Island wrapper div has zero rendered
171
+ // dimensions. IntersectionObserver fires isIntersecting=false on desktop
172
+ // (zero-size element) → Preact hydrate() is never called. On mobile (and
173
+ // on desktop→mobile resize) the children become visible, the element gains
174
+ // size, IO fires isIntersecting=true, and hydration completes normally.
175
+ //
176
+ // Residual: data-props JSON (~2.4 KB) is still emitted in the SSR HTML on
177
+ // every page regardless of viewport, because it is serialised at build time
178
+ // and not gated by media. Eliminating it requires a zfb "media" hydration
179
+ // strategy — tracked in Takazudo/zudo-front-builder#969.
214
180
  const sidebarToggle = Island({
215
- when: "load",
181
+ when: "visible",
216
182
  children: (
217
183
  <SidebarToggle
218
184
  nodes={sidebarNodes}
@@ -225,15 +191,12 @@ export function HeaderWithDefaults(
225
191
  ),
226
192
  }) as unknown as VNode;
227
193
 
228
- // Wrap the host's local ThemeToggle in Island({when:"load"}) so the SSG
194
+ // Wrap the bare ThemeToggle in Island({when:"load"}) so the SSG
229
195
  // output emits a data-zfb-island="ThemeToggle" marker the hydration
230
- // runtime can find — matching the documented header contract. The v2
231
- // package's <ThemeToggle> already does this internally, but importing it
232
- // forces the v2 theme barrel into the bundle (see import note at the top
233
- // of this file).
196
+ // runtime can find — matching the documented header contract.
234
197
  const themeToggle = Island({
235
198
  when: "load",
236
- children: <ThemeToggle />,
199
+ children: <ThemeToggle defaultMode={themeDefaultMode} />,
237
200
  }) as unknown as VNode;
238
201
 
239
202
  // Locale-aware search widget. Renders the full dialog markup in SSR
@@ -269,7 +232,9 @@ export function HeaderWithDefaults(
269
232
  );
270
233
  // "Latest" entry links to the current page in the latest (unversioned)
271
234
  // docs when a slug is available, or falls back to the versions index page.
272
- const latestUrl = currentSlug
235
+ // Null check, not truthiness: "" is the canonical root-index slug (#1891)
236
+ // and must produce real per-version root URLs.
237
+ const latestUrl = currentSlug != null
273
238
  ? docsUrl(currentSlug, lang)
274
239
  : versionsPageUrl;
275
240
 
@@ -278,7 +243,7 @@ export function HeaderWithDefaults(
278
243
  // index — matching the documented version-switcher contract.
279
244
  const versionUrls: Record<string, string> = {};
280
245
  for (const v of settings.versions) {
281
- versionUrls[v.slug] = currentSlug
246
+ versionUrls[v.slug] = currentSlug != null
282
247
  ? versionedDocsUrl(currentSlug, v.slug, lang)
283
248
  : versionsPageUrl;
284
249
  }
@@ -51,13 +51,14 @@ export function buildInlineVersionSwitcher(
51
51
  const versionsPageUrl = withBase(
52
52
  isNonDefaultLocale ? `/${locale}/docs/versions` : "/docs/versions",
53
53
  );
54
- const latestUrl = slug ? docsUrl(slug, locale) : versionsPageUrl;
54
+ // The docs root index has the canonical empty slug "" (#1891), and
55
+ // docsUrl("")/versionedDocsUrl("") resolve to the per-version docs roots —
56
+ // so an empty slug must NOT fall back to the versions page.
57
+ const latestUrl = docsUrl(slug, locale);
55
58
 
56
59
  const versionUrls: Record<string, string> = {};
57
60
  for (const v of settings.versions) {
58
- versionUrls[v.slug] = slug
59
- ? versionedDocsUrl(slug, v.slug, locale)
60
- : versionsPageUrl;
61
+ versionUrls[v.slug] = versionedDocsUrl(slug, v.slug, locale);
61
62
  }
62
63
 
63
64
  const labels: VersionSwitcherLabels = {