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
@@ -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,30 @@ 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
+ // BARE (non-island-wrapped) ThemeToggle from the dedicated subpath
49
+ // (#2012 E2). The `./theme` barrel exports an Island-wrapped variant;
50
+ // this wrapper composes its own Island below, and the bare subpath
51
+ // avoids nesting an island inside an island.
52
+ import { ThemeToggle } from "@takazudo/zudo-doc/theme-toggle";
56
53
  import SidebarToggle from "@/components/sidebar-toggle";
57
54
  import { settings } from "@/config/settings";
58
55
  import { defaultLocale, locales, t, type Locale } from "@/config/i18n";
59
56
  import { buildGitHubRepoUrl } from "@/utils/github";
60
57
  import {
61
- buildLocaleLinks,
62
58
  docsUrl,
63
59
  navHref,
64
60
  stripBase,
65
61
  versionedDocsUrl,
66
62
  withBase,
67
63
  } from "@/utils/base";
68
- import {
69
- type NavNode,
70
- } from "@/utils/docs";
71
- import { buildSidebarForSection } from "@/utils/sidebar";
72
64
  import { filterHeaderRightItems } from "@takazudo/zudo-doc/header";
73
65
  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
- }
66
+ import {
67
+ buildRootMenuItems,
68
+ buildLocaleLinksForNav,
69
+ buildSidebarNodes,
70
+ getThemeDefaultMode,
71
+ } from "./_nav-data-prep";
110
72
 
111
73
  // ---------------------------------------------------------------------------
112
74
  // Component
@@ -114,7 +76,7 @@ function remapVersionedHrefs(
114
76
 
115
77
  export interface HeaderWithDefaultsProps {
116
78
  /** Active locale; defaults to the configured defaultLocale. */
117
- lang?: Locale;
79
+ lang?: Locale | string;
118
80
  /**
119
81
  * Current page URL path (as the layout passes from Astro.url.pathname or
120
82
  * the zfb equivalent). Used by the Header to compute the active nav item
@@ -151,27 +113,21 @@ export function HeaderWithDefaults(
151
113
  props: HeaderWithDefaultsProps,
152
114
  ): JSX.Element {
153
115
  const {
154
- lang = defaultLocale,
116
+ lang: langProp = defaultLocale,
155
117
  currentPath = "",
156
118
  currentVersion,
157
119
  currentSlug,
158
120
  navSection,
159
121
  } = props;
122
+ // Route params arrive as `string`; cast to Locale since keys of settings.locales
123
+ // are always valid locale codes. The prop accepts `Locale | string` so callers
124
+ // without a Locale variable don't need to cast (e.g. _tag-pages.tsx).
125
+ const lang = langProp as Locale;
160
126
 
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
- }));
127
+ // Root-menu items, locale links, sidebar nodes, and theme mode — all
128
+ // delegated to the shared _nav-data-prep helpers so header and sidebar
129
+ // wrappers stay in sync without duplicating the logic.
130
+ const rootMenuItems = buildRootMenuItems(lang, currentVersion);
175
131
 
176
132
  // Build the mobile sidebar toggle unconditionally — SidebarToggle is rendered
177
133
  // on every page (refs #1453); the host CSS hides it where unneeded. When navSection is
@@ -182,21 +138,11 @@ export function HeaderWithDefaults(
182
138
 
183
139
  // Locale-switcher links in the mobile sidebar footer — only when
184
140
  // multiple locales are configured (mirrors _sidebar-with-defaults.tsx).
185
- const localeLinks =
186
- locales.length > 1 ? buildLocaleLinks(currentPath, lang) : undefined;
141
+ const localeLinks = buildLocaleLinksForNav(currentPath, lang, locales.length);
187
142
 
188
- const themeDefaultMode = settings.colorMode
189
- ? settings.colorMode.defaultMode
190
- : undefined;
143
+ const themeDefaultMode = getThemeDefaultMode();
191
144
 
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
- }
145
+ const sidebarNodes = buildSidebarNodes(lang, navSection, currentVersion);
200
146
 
201
147
  // Wrap SidebarToggle (hamburger button + slide-in aside + SidebarTree) in
202
148
  // Island so the SSG output carries the full tree HTML AND the
@@ -211,8 +157,25 @@ export function HeaderWithDefaults(
211
157
  // nested as a JSX child its data was dropped during hydration and
212
158
  // SidebarToggle re-rendered with `children=undefined`, wiping the SSR
213
159
  // tree DOM. zudolab/zudo-doc#1355 wave 13.5.
160
+ //
161
+ // C4 — media-gated hydration. zfb only supports load|idle|visible
162
+ // strategies (no "media" strategy; matchMedia inside the component is too
163
+ // late — props are already emitted, bundle already downloaded).
164
+ // Upstream feature request: Takazudo/zudo-front-builder#969.
165
+ //
166
+ // Best achievable downstream: when="visible" + all SidebarToggle children
167
+ // are lg:hidden, so on desktop the Island wrapper div has zero rendered
168
+ // dimensions. IntersectionObserver fires isIntersecting=false on desktop
169
+ // (zero-size element) → Preact hydrate() is never called. On mobile (and
170
+ // on desktop→mobile resize) the children become visible, the element gains
171
+ // size, IO fires isIntersecting=true, and hydration completes normally.
172
+ //
173
+ // Residual: data-props JSON (~2.4 KB) is still emitted in the SSR HTML on
174
+ // every page regardless of viewport, because it is serialised at build time
175
+ // and not gated by media. Eliminating it requires a zfb "media" hydration
176
+ // strategy — tracked in Takazudo/zudo-front-builder#969.
214
177
  const sidebarToggle = Island({
215
- when: "load",
178
+ when: "visible",
216
179
  children: (
217
180
  <SidebarToggle
218
181
  nodes={sidebarNodes}
@@ -225,15 +188,12 @@ export function HeaderWithDefaults(
225
188
  ),
226
189
  }) as unknown as VNode;
227
190
 
228
- // Wrap the host's local ThemeToggle in Island({when:"load"}) so the SSG
191
+ // Wrap the bare ThemeToggle in Island({when:"load"}) so the SSG
229
192
  // 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).
193
+ // runtime can find — matching the documented header contract.
234
194
  const themeToggle = Island({
235
195
  when: "load",
236
- children: <ThemeToggle />,
196
+ children: <ThemeToggle defaultMode={themeDefaultMode} />,
237
197
  }) as unknown as VNode;
238
198
 
239
199
  // Locale-aware search widget. Renders the full dialog markup in SSR
@@ -269,7 +229,9 @@ export function HeaderWithDefaults(
269
229
  );
270
230
  // "Latest" entry links to the current page in the latest (unversioned)
271
231
  // docs when a slug is available, or falls back to the versions index page.
272
- const latestUrl = currentSlug
232
+ // Null check, not truthiness: "" is the canonical root-index slug (#1891)
233
+ // and must produce real per-version root URLs.
234
+ const latestUrl = currentSlug != null
273
235
  ? docsUrl(currentSlug, lang)
274
236
  : versionsPageUrl;
275
237
 
@@ -278,7 +240,7 @@ export function HeaderWithDefaults(
278
240
  // index — matching the documented version-switcher contract.
279
241
  const versionUrls: Record<string, string> = {};
280
242
  for (const v of settings.versions) {
281
- versionUrls[v.slug] = currentSlug
243
+ versionUrls[v.slug] = currentSlug != null
282
244
  ? versionedDocsUrl(currentSlug, v.slug, lang)
283
245
  : versionsPageUrl;
284
246
  }
@@ -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 = {
@@ -0,0 +1,137 @@
1
+ // Shared nav data-prep utilities used by both _header-with-defaults.tsx
2
+ // and _sidebar-with-defaults.tsx.
3
+ //
4
+ // Extracted to avoid maintaining four near-identical copies: the two host
5
+ // modules above plus their template mirrors under
6
+ // packages/create-zudo-doc/templates/base/pages/lib/.
7
+
8
+ import { settings } from "@/config/settings";
9
+ import { t, type Locale } from "@/config/i18n";
10
+ import {
11
+ buildLocaleLinks,
12
+ navHref,
13
+ versionedDocsUrl,
14
+ } from "@/utils/base";
15
+ import { type NavNode } from "@/utils/docs";
16
+ import { buildSidebarForSection } from "@/utils/sidebar";
17
+ import { loadNavSourceDocs } from "./_nav-source-docs";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // remapVersionedHrefs
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Walk the nav tree and rewrite each node's `href` to its versioned form.
25
+ *
26
+ * `buildNavTree` always emits hrefs via `docsUrl()`; when the active route
27
+ * lives under `/v/{version}/...` we need the same nodes pointing at the
28
+ * versioned URL so internal nav clicks stay inside the version. Skips
29
+ * nodes without an href (link-only or category placeholders).
30
+ */
31
+ export function remapVersionedHrefs(
32
+ nodes: NavNode[],
33
+ version: string,
34
+ nodeLang: Locale,
35
+ ): NavNode[] {
36
+ return nodes.map((node) => {
37
+ const children =
38
+ node.children.length > 0
39
+ ? remapVersionedHrefs(node.children, version, nodeLang)
40
+ : node.children;
41
+
42
+ if (!node.href || node.slug.startsWith("__link__")) {
43
+ return children !== node.children ? { ...node, children } : node;
44
+ }
45
+
46
+ const newHref = versionedDocsUrl(node.slug, version, nodeLang);
47
+ return { ...node, href: newHref, children };
48
+ });
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // buildRootMenuItems
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Root-menu items derived from settings.headerNav (mobile "back to menu" list).
57
+ *
58
+ * Used by both header and sidebar wrappers — the same nav data feeds both the
59
+ * mobile SidebarToggle (header) and the desktop SidebarTree (sidebar).
60
+ */
61
+ export function buildRootMenuItems(
62
+ lang: Locale,
63
+ currentVersion?: string,
64
+ ) {
65
+ return settings.headerNav.map((item) => ({
66
+ label: item.labelKey
67
+ ? t(item.labelKey as Parameters<typeof t>[0], lang)
68
+ : item.label,
69
+ href: navHref(item.path, lang, currentVersion),
70
+ children: item.children?.map((child) => ({
71
+ label: child.labelKey
72
+ ? t(child.labelKey as Parameters<typeof t>[0], lang)
73
+ : child.label,
74
+ href: navHref(child.path, lang, currentVersion),
75
+ })),
76
+ }));
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // buildLocaleLinksForNav
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Locale-switcher links for the mobile sidebar footer and language switcher.
85
+ * Returns `undefined` when only one locale is configured (single-locale guard).
86
+ */
87
+ export function buildLocaleLinksForNav(
88
+ currentPath: string,
89
+ lang: Locale,
90
+ localeCount: number,
91
+ ) {
92
+ return localeCount > 1 ? buildLocaleLinks(currentPath, lang) : undefined;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // buildSidebarNodes
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Build the resolved sidebar node list for a given section + version.
101
+ *
102
+ * Loads the nav source, filters to the active section, then optionally
103
+ * remaps hrefs for versioned routes.
104
+ *
105
+ * `emptyWhenUnsectioned` controls the `navSection === undefined` case —
106
+ * the two legacy call sites deliberately disagreed: the header's mobile
107
+ * drawer returned `[]` (root menu only), while the desktop sidebar fell
108
+ * through to `buildSidebarForSection(..., undefined)` = the FULL tree
109
+ * (pages whose slug matches no headerNav categoryMatch still get a
110
+ * sidebar). Collapsing both to `[]` shipped an empty desktop sidebar for
111
+ * unsectioned pages — keep the divergence explicit here.
112
+ */
113
+ export function buildSidebarNodes(
114
+ lang: Locale,
115
+ navSection: string | undefined,
116
+ currentVersion?: string,
117
+ emptyWhenUnsectioned = true,
118
+ ): NavNode[] {
119
+ if (navSection === undefined && emptyWhenUnsectioned) return [];
120
+ const { navDocs, categoryMeta } = loadNavSourceDocs(lang, currentVersion);
121
+ const rawNodes = buildSidebarForSection(navDocs, lang, navSection, categoryMeta);
122
+ return currentVersion
123
+ ? remapVersionedHrefs(rawNodes, currentVersion, lang)
124
+ : rawNodes;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // themeDefaultMode
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /**
132
+ * Extract the configured default color mode from settings.
133
+ * Returns `undefined` when color mode is not configured (single-scheme projects).
134
+ */
135
+ export function getThemeDefaultMode() {
136
+ return settings.colorMode ? settings.colorMode.defaultMode : undefined;
137
+ }
@@ -21,7 +21,7 @@
21
21
  // - route-enumerators.ts (sitemap) and the MDX nav wrappers
22
22
  // each picking the `NavSourceVariant` matching its filter needs.
23
23
 
24
- import { defaultLocale, type Locale } from "@/config/i18n";
24
+ import { defaultLocale, getLocaleConfig, type Locale } from "@/config/i18n";
25
25
  import { settings } from "@/config/settings";
26
26
  import {
27
27
  loadCategoryMeta,
@@ -84,7 +84,7 @@ export type NavSourceDocs = {
84
84
  categoryMeta: Map<string, CategoryMeta>;
85
85
  /** Slugs that came from the locale collection (for isFallback). Empty for
86
86
  * default-locale / single-collection cases. */
87
- localeSlugSet: Set<string>;
87
+ localeSlugSet: ReadonlySet<string>;
88
88
  };
89
89
 
90
90
  /**
@@ -124,7 +124,11 @@ export function resolveNavSource(
124
124
  // pages in sync. Otherwise (default locale, or the version not configured
125
125
  // for this locale) fall back to the version's EN base collection.
126
126
  if (currentVersion) {
127
- const versionConfig = settings.versions?.find((v) => v.slug === currentVersion);
127
+ // `versions` is `VersionConfig[] | false` — `false?.find` would throw
128
+ // (optional chaining only short-circuits on null/undefined).
129
+ const versionConfig = Array.isArray(settings.versions)
130
+ ? settings.versions.find((v) => v.slug === currentVersion)
131
+ : undefined;
128
132
  const localeDir = versionConfig?.locales?.[lang]?.dir;
129
133
  if (lang !== defaultLocale && localeDir) {
130
134
  return resolveVersionedLocaleSource(
@@ -138,7 +142,7 @@ export function resolveNavSource(
138
142
  const docs = stableDocs(`docs-v-${currentVersion}`);
139
143
  const categoryMeta = loadCategoryMeta(versionConfig?.docsDir ?? settings.docsDir);
140
144
  const navDocs = stableNavDocs(docs);
141
- return { docs, navDocs, categoryMeta, localeSlugSet: EMPTY_SLUG_SET as Set<string> };
145
+ return { docs, navDocs, categoryMeta, localeSlugSet: EMPTY_SLUG_SET };
142
146
  }
143
147
 
144
148
  // --- Default locale: the "docs" collection directly.
@@ -146,7 +150,7 @@ export function resolveNavSource(
146
150
  const docs = stableDocs("docs");
147
151
  const categoryMeta = loadCategoryMeta(settings.docsDir);
148
152
  const navDocs = stableNavDocs(docs);
149
- return { docs, navDocs, categoryMeta, localeSlugSet: EMPTY_SLUG_SET as Set<string> };
153
+ return { docs, navDocs, categoryMeta, localeSlugSet: EMPTY_SLUG_SET };
150
154
  }
151
155
 
152
156
  // --- Non-default locale: locale-first merge with EN fallback.
@@ -163,7 +167,7 @@ export function resolveNavSource(
163
167
  );
164
168
  const docs = merged.docs;
165
169
 
166
- const localeDir = settings.locales[lang]?.dir ?? settings.docsDir;
170
+ const localeDir = getLocaleConfig(lang)?.dir ?? settings.docsDir;
167
171
  const categoryMeta = stableMergeCategoryMeta(settings.docsDir, localeDir);
168
172
  const navDocs = stableNavDocs(docs);
169
173
 
@@ -41,20 +41,35 @@ export const SEARCH_WIDGET_SCRIPT = /* javascript */ `(function () {
41
41
  return query.trim().split(/\\s+/).filter(Boolean);
42
42
  }
43
43
 
44
+ // scoreEntry reads pre-lowercased fields (_titleLc, _descLc, _bodyLc)
45
+ // set by prepareLc() at index-load time. Terms arrive already lowercased
46
+ // from search() so no per-call toLowerCase() is needed.
44
47
  function scoreEntry(entry, terms) {
45
48
  var score = 0;
46
- var titleLower = (entry.title || "").toLowerCase();
47
- var bodyLower = (entry.body || "").toLowerCase();
48
- var descLower = (entry.description || "").toLowerCase();
49
+ var titleLc = entry._titleLc;
50
+ var descLc = entry._descLc;
51
+ var bodyLc = entry._bodyLc;
49
52
  for (var i = 0; i < terms.length; i++) {
50
- var t = terms[i].toLowerCase();
51
- if (titleLower.indexOf(t) !== -1) score += 3;
52
- if (descLower.indexOf(t) !== -1) score += 2;
53
- if (bodyLower.indexOf(t) !== -1) score += 1;
53
+ var t = terms[i];
54
+ if (titleLc.indexOf(t) !== -1) score += 3;
55
+ if (descLc.indexOf(t) !== -1) score += 2;
56
+ if (bodyLc.indexOf(t) !== -1) score += 1;
54
57
  }
55
58
  return score;
56
59
  }
57
60
 
61
+ // Pre-lowercase the searched fields on each entry once at load time so that
62
+ // scoreEntry() does not re-lowercase the entire ~162 KB index on every
63
+ // debounced keystroke. Original-case fields are preserved for display.
64
+ function prepareLc(entries) {
65
+ for (var i = 0; i < entries.length; i++) {
66
+ var e = entries[i];
67
+ e._titleLc = (e.title || "").toLowerCase();
68
+ e._descLc = (e.description || "").toLowerCase();
69
+ e._bodyLc = (e.body || "").toLowerCase();
70
+ }
71
+ }
72
+
58
73
  function highlightTerms(text, terms) {
59
74
  if (!terms.length) return escapeHtml(text);
60
75
  var escaped = terms.map(function(t) { return escapeRegExp(t); });
@@ -99,6 +114,7 @@ export const SEARCH_WIDGET_SCRIPT = /* javascript */ `(function () {
99
114
  this._countNarrow = null;
100
115
  this._entries = null;
101
116
  this._loading = false;
117
+ this._indexUnavailable = false;
102
118
  this._debounce = null;
103
119
  this._currentQuery = "";
104
120
  this._allResults = [];
@@ -231,6 +247,7 @@ export const SEARCH_WIDGET_SCRIPT = /* javascript */ `(function () {
231
247
  })
232
248
  .then(function(data) {
233
249
  self._entries = Array.isArray(data) ? data : (data.entries || []);
250
+ prepareLc(self._entries);
234
251
  self._loading = false;
235
252
  // If user already typed, search now
236
253
  if (self._input && self._input.value.trim()) {
@@ -239,7 +256,10 @@ export const SEARCH_WIDGET_SCRIPT = /* javascript */ `(function () {
239
256
  })
240
257
  .catch(function() {
241
258
  self._loading = false;
242
- // Index unavailable — silently degrade
259
+ self._indexUnavailable = true;
260
+ if (self._results) {
261
+ self._results.innerHTML = "<p class=\\"text-small text-muted\\">Search unavailable</p>";
262
+ }
243
263
  });
244
264
  }
245
265
 
@@ -264,7 +284,10 @@ export const SEARCH_WIDGET_SCRIPT = /* javascript */ `(function () {
264
284
  return;
265
285
  }
266
286
 
267
- var terms = parseTerms(query);
287
+ // Lowercase the query terms once here so scoreEntry() can do plain
288
+ // indexOf() against pre-lowercased entry fields without repeating
289
+ // toLowerCase() across the entire index on every keystroke.
290
+ var terms = parseTerms(query).map(function(t) { return t.toLowerCase(); });
268
291
  var scored = [];
269
292
  for (var i = 0; i < this._entries.length; i++) {
270
293
  var s = scoreEntry(this._entries[i], terms);