@takazudo/zudo-doc 5.5.3 → 5.7.0

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/CHANGELOG.md +51 -0
  2. package/README.md +39 -0
  3. package/dist/chrome/derive.d.ts +46 -0
  4. package/dist/chrome/derive.js +6 -2
  5. package/dist/config-assertions/index.d.ts +31 -0
  6. package/dist/config-assertions/index.js +24 -0
  7. package/dist/config.d.ts +23 -2
  8. package/dist/config.js +3 -0
  9. package/dist/current-path/index.d.ts +27 -0
  10. package/dist/current-path/index.js +11 -0
  11. package/dist/design-token-panel-bootstrap.d.ts +89 -23
  12. package/dist/design-token-panel-bootstrap.js +19 -6
  13. package/dist/doc-page-props/index.d.ts +5 -67
  14. package/dist/doc-route-entries/index.d.ts +10 -95
  15. package/dist/doc-route-entries/index.js +1 -78
  16. package/dist/doc-route-paths/index.d.ts +1 -1
  17. package/dist/head-with-defaults/index.d.ts +3 -1
  18. package/dist/head-with-defaults/index.js +76 -4
  19. package/dist/header/nav-active.d.ts +31 -0
  20. package/dist/header/nav-active.js +2 -1
  21. package/dist/header/nav-overflow-generated-script.d.ts +11 -0
  22. package/dist/header/nav-overflow-generated-script.js +302 -0
  23. package/dist/header/nav-overflow-script.d.ts +1 -1
  24. package/dist/header/nav-overflow-script.js +1 -286
  25. package/dist/header-with-defaults/index.js +11 -2
  26. package/dist/i18n-version/language-switcher.d.ts +6 -0
  27. package/dist/i18n-version/language-switcher.js +3 -1
  28. package/dist/i18n-version/version-switcher.d.ts +6 -0
  29. package/dist/i18n-version/version-switcher.js +3 -1
  30. package/dist/md-utils/index.js +33 -1
  31. package/dist/nav-source-docs/index.d.ts +7 -11
  32. package/dist/plugins/route-pages-candidates.d.ts +19 -0
  33. package/dist/plugins/route-pages-candidates.js +17 -0
  34. package/dist/plugins/routes.d.ts +46 -0
  35. package/dist/plugins/routes.js +72 -18
  36. package/dist/preset.d.ts +12 -1
  37. package/dist/preset.js +2 -0
  38. package/dist/route-context/index.js +2 -2
  39. package/dist/routes/_chrome.d.ts +1 -1
  40. package/dist/routes/_chrome.js +4 -0
  41. package/dist/routes/_context.d.ts +3 -3
  42. package/dist/routes/_design-token-panel-bootstrap.d.ts +18 -0
  43. package/dist/routes/_design-token-panel-bootstrap.js +11 -0
  44. package/dist/routes/_docs-helpers.d.ts +1 -36
  45. package/dist/routes/_docs-helpers.js +0 -138
  46. package/dist/safelist.css +1 -1
  47. package/dist/search-widget-script/generated-script.d.ts +8 -0
  48. package/dist/search-widget-script/generated-script.js +465 -0
  49. package/dist/search-widget-script/index.d.ts +1 -18
  50. package/dist/search-widget-script/index.js +1 -443
  51. package/dist/settings.d.ts +82 -1
  52. package/dist/sidebar-toggle-island/index.js +2 -1
  53. package/dist/sidebar-tree/category-meta.d.ts +9 -0
  54. package/dist/sidebar-tree/category-meta.js +21 -12
  55. package/dist/sidebar-tree-island/index.d.ts +8 -1
  56. package/dist/sidebar-tree-island/index.js +16 -14
  57. package/dist/site-schema/doc-route-entries.d.ts +89 -0
  58. package/dist/site-schema/doc-route-entries.js +83 -0
  59. package/dist/site-schema/index.d.ts +17 -0
  60. package/dist/site-schema/index.js +46 -0
  61. package/dist/site-schema/nav-tree.d.ts +28 -0
  62. package/dist/site-schema/nav-tree.js +138 -0
  63. package/dist/site-schema/types.d.ts +97 -0
  64. package/dist/site-schema/types.js +0 -0
  65. package/dist/theme/theme-pack-provider.d.ts +34 -3
  66. package/dist/theme/theme-pack-provider.js +30 -2
  67. package/dist/transitions/index.d.ts +1 -0
  68. package/dist/transitions/index.js +2 -0
  69. package/dist/transitions/nested-island-props-refresh.d.ts +19 -0
  70. package/dist/transitions/nested-island-props-refresh.js +104 -0
  71. package/eject/header/header.tsx +13 -5
  72. package/eject/header/nav-active.ts +16 -1
  73. package/eject/header/nav-class-tokens.ts +9 -5
  74. package/eject/header/nav-overflow-generated-script.ts +29 -0
  75. package/eject/header/nav-overflow-script.ts +26 -304
  76. package/eject/sidebar-toggle-island/index.tsx +13 -1
  77. package/eject/sidebar-tree-island/index.tsx +44 -20
  78. package/package.json +25 -12
  79. package/routes-src/_chrome.tsx +21 -9
  80. package/routes-src/_design-token-panel-bootstrap.tsx +63 -0
  81. package/routes-src/_docs-helpers.ts +18 -225
  82. package/routes-src/_virtual.d.ts +5 -2
  83. package/virtual-modules.d.ts +5 -2
@@ -29,307 +29,29 @@
29
29
  // Kept as a separate module (rather than inlined in `header.tsx`) so
30
30
  // the JSX file stays focused on markup and so future edits to the
31
31
  // script can be reviewed in isolation.
32
-
33
- import { AFTER_NAVIGATE_EVENT } from "../transitions/page-events.js";
34
- import {
35
- NAV_CHEVRON_ACTIVE,
36
- NAV_CHEVRON_INACTIVE,
37
- NAV_CHILD_ACTIVE,
38
- NAV_CHILD_INACTIVE,
39
- NAV_MENU_CHILD_ACTIVE,
40
- NAV_MENU_CHILD_INACTIVE,
41
- NAV_MENU_PARENT,
42
- NAV_MENU_PARENT_ACTIVE_SUFFIX,
43
- NAV_MENU_PLAIN,
44
- NAV_MENU_PLAIN_ACTIVE_SUFFIX,
45
- NAV_TOP_ACTIVE,
46
- NAV_TOP_INACTIVE,
47
- } from "./nav-class-tokens.js";
48
-
49
- // The class lists spliced into the script below are the SSR ↔ runtime
50
- // lockstep: they must match the strings header.tsx renders. Both files import
51
- // them from ./nav-class-tokens so they cannot drift (zudolab/zudo-doc#3023).
52
- // The script ships as plain text via dangerouslySetInnerHTML and cannot import
53
- // the arrays at runtime, so these helpers splice the token lists into the
54
- // script string at module-eval (build) time instead.
55
-
56
- // -> `"bg-fg", "text-bg"` — argument list for a classList.add/remove(...) call.
57
- const clsArgs = (tokens: readonly string[]): string =>
58
- tokens.map((token) => JSON.stringify(token)).join(", ");
59
-
60
- // -> `"bg-fg text-bg"` — a single class-string literal for `className = ...`.
61
- const clsLiteral = (tokens: readonly string[]): string =>
62
- JSON.stringify(tokens.join(" "));
63
-
64
- // -> `" font-bold text-accent"` — leading-space append for `className += ...`.
65
- const clsAppend = (tokens: readonly string[]): string =>
66
- JSON.stringify(" " + tokens.join(" "));
67
-
68
- export const NAV_OVERFLOW_SCRIPT = `(function () {
69
- var cleanupNavOverflow = null;
70
-
71
- function trimSlashes(p) {
72
- while (p.length > 1 && p.charAt(p.length - 1) === "/") p = p.slice(0, -1);
73
- return p || "/";
74
- }
75
-
76
- function navPathname(a) {
77
- try { return trimSlashes(new URL(a.href, location.href).pathname); }
78
- catch (e) { return ""; }
79
- }
80
-
81
- function isUnderPath(cur, p) {
82
- if (!p) return false;
83
- if (cur === p) return true;
84
- return p !== "/" && cur.indexOf(p + "/") === 0;
85
- }
86
-
87
- // Recompute which header nav item is "active" from the CURRENT URL and
88
- // repaint the highlight. SSR sets the active item on first paint, but the
89
- // header is persisted across same-locale client-router swaps
90
- // (data-zfb-transition-persist), so without this the highlight would stay
91
- // frozen on the page where the header was first rendered. Mirrors the
92
- // sidebar island's client-side approach (match location.pathname against
93
- // each entry's href) and the SSR longest-match + dropdown-parent rules.
94
- // URL-based: hrefs and location.pathname both carry the base + locale
95
- // prefix, so they compare directly without stripping.
96
- function applyActiveNav() {
97
- var nav = document.querySelector("[data-header-nav]");
98
- if (!nav) return;
99
- var topItems = Array.from(nav.querySelectorAll(":scope > [data-nav-item]"));
100
- if (topItems.length === 0) return;
101
-
102
- var cur = trimSlashes(location.pathname);
103
-
104
- // Deepest (longest) nav path the current URL lives under, across both
105
- // top-level and dropdown-child paths — matches computeActiveNavPath.
106
- var activePath = "";
107
- topItems.forEach(function (it) {
108
- var isDropdown = it.hasAttribute("data-nav-item-dropdown");
109
- var topA = isDropdown ? it.querySelector(":scope > a") : it;
110
- if (topA) {
111
- var tp = navPathname(topA);
112
- if (isUnderPath(cur, tp) && tp.length > activePath.length) activePath = tp;
113
- }
114
- if (isDropdown) {
115
- it.querySelectorAll(":scope > div a").forEach(function (c) {
116
- var cp = navPathname(c);
117
- if (isUnderPath(cur, cp) && cp.length > activePath.length) activePath = cp;
118
- });
119
- }
120
- });
121
-
122
- function setTopActive(a, active) {
123
- if (!a) return;
124
- if (active) {
125
- a.classList.add(${clsArgs(NAV_TOP_ACTIVE)});
126
- a.classList.remove(${clsArgs(NAV_TOP_INACTIVE)});
127
- a.setAttribute("aria-current", "page");
128
- } else {
129
- a.classList.remove(${clsArgs(NAV_TOP_ACTIVE)});
130
- a.classList.add(${clsArgs(NAV_TOP_INACTIVE)});
131
- a.removeAttribute("aria-current");
132
- }
133
- }
134
-
135
- topItems.forEach(function (it) {
136
- var isDropdown = it.hasAttribute("data-nav-item-dropdown");
137
- var topA = isDropdown ? it.querySelector(":scope > a") : it;
138
- var topActive = false;
139
-
140
- if (isDropdown) {
141
- var parentMatch = !!topA && navPathname(topA) === activePath && activePath !== "";
142
- var anyChild = false;
143
- it.querySelectorAll(":scope > div a").forEach(function (c) {
144
- var childActive = navPathname(c) === activePath && activePath !== "";
145
- if (childActive) {
146
- anyChild = true;
147
- c.setAttribute("data-active", "");
148
- c.classList.add(${clsArgs(NAV_CHILD_ACTIVE)});
149
- c.classList.remove(${clsArgs(NAV_CHILD_INACTIVE)});
150
- } else {
151
- c.removeAttribute("data-active");
152
- c.classList.remove(${clsArgs(NAV_CHILD_ACTIVE)});
153
- c.classList.add(${clsArgs(NAV_CHILD_INACTIVE)});
154
- }
155
- });
156
- topActive = parentMatch || anyChild;
157
- var svg = topA ? topA.querySelector("svg") : null;
158
- if (svg) {
159
- if (topActive) { svg.classList.add(${clsArgs(NAV_CHEVRON_ACTIVE)}); svg.classList.remove(${clsArgs(NAV_CHEVRON_INACTIVE)}); }
160
- else { svg.classList.add(${clsArgs(NAV_CHEVRON_INACTIVE)}); svg.classList.remove(${clsArgs(NAV_CHEVRON_ACTIVE)}); }
161
- }
162
- } else {
163
- topActive = activePath !== "" && navPathname(topA) === activePath;
164
- }
165
-
166
- setTopActive(topA, topActive);
167
- });
168
- }
169
-
170
- function initNavOverflow() {
171
- if (cleanupNavOverflow) cleanupNavOverflow();
172
-
173
- // Repaint the active highlight for the current URL before measuring /
174
- // cloning, so the overflow "···" menu mirrors the correct active state.
175
- applyActiveNav();
176
-
177
- var nav = document.querySelector("[data-header-nav]");
178
- var moreContainer = document.querySelector("[data-nav-more]");
179
- var moreMenu = document.querySelector("[data-nav-more-menu]");
180
- var moreToggle = document.querySelector("[data-nav-more-toggle]");
181
- if (!nav || !moreContainer || !moreMenu || !moreToggle) return;
182
-
183
- var items = Array.from(nav.querySelectorAll(":scope > [data-nav-item]"));
184
- if (items.length === 0) return;
185
-
186
- var controller = new AbortController();
187
-
188
- function update() {
189
- items.forEach(function (el) { el.style.display = ""; });
190
- moreContainer.style.display = "";
191
- moreMenu.innerHTML = "";
192
- moreMenu.classList.add("hidden");
193
- moreToggle.setAttribute("aria-expanded", "false");
194
-
195
- var itemWidths = items.map(function (el) { return el.offsetWidth; });
196
- var moreWidth = moreContainer.offsetWidth;
197
- var navGap = parseFloat(getComputedStyle(nav).columnGap) || 0;
198
- var available = nav.clientWidth;
199
-
200
- if (available <= 0) {
201
- moreContainer.style.display = "none";
202
- return;
203
- }
204
-
205
- var total = 0;
206
- for (var i = 0; i < itemWidths.length; i++) {
207
- total += itemWidths[i] + (i > 0 ? navGap : 0);
208
- }
209
-
210
- if (total <= available) {
211
- moreContainer.style.display = "none";
212
- return;
213
- }
214
-
215
- var used = 0;
216
- var cutoffIndex = 0;
217
-
218
- for (var i2 = 0; i2 < items.length; i2++) {
219
- var w = itemWidths[i2] + (i2 > 0 ? navGap : 0);
220
- if (used + w > available - moreWidth - navGap) break;
221
- used += w;
222
- cutoffIndex = i2 + 1;
223
- }
224
-
225
- for (var i3 = cutoffIndex; i3 < items.length; i3++) {
226
- items[i3].style.display = "none";
227
- }
228
-
229
- for (var i4 = cutoffIndex; i4 < items.length; i4++) {
230
- var el = items[i4];
231
- var isDropdown = el.hasAttribute("data-nav-item-dropdown");
232
-
233
- if (isDropdown) {
234
- var parentLink = el.querySelector(":scope > a");
235
- var childLinks = el.querySelectorAll(":scope > div a");
236
- if (parentLink) {
237
- var li = document.createElement("li");
238
- var a = document.createElement("a");
239
- a.href = parentLink.href;
240
- var parentText = parentLink.textContent ? parentLink.textContent.trim().replace(/\\s+/g, " ") : "";
241
- a.textContent = parentText;
242
- a.className = ${clsLiteral(NAV_MENU_PARENT)};
243
- if (parentLink.getAttribute("aria-current") === "page") {
244
- a.className += ${clsAppend(NAV_MENU_PARENT_ACTIVE_SUFFIX)};
245
- }
246
- li.appendChild(a);
247
- moreMenu.appendChild(li);
248
- }
249
- childLinks.forEach(function (child) {
250
- var li = document.createElement("li");
251
- var a = document.createElement("a");
252
- a.href = child.href;
253
- a.textContent = child.textContent ? child.textContent.trim() : "";
254
- var isChildActive = child.hasAttribute("data-active");
255
- a.className = isChildActive
256
- ? ${clsLiteral(NAV_MENU_CHILD_ACTIVE)}
257
- : ${clsLiteral(NAV_MENU_CHILD_INACTIVE)};
258
- li.appendChild(a);
259
- moreMenu.appendChild(li);
260
- });
261
- } else {
262
- var anchor = el;
263
- var li2 = document.createElement("li");
264
- var a2 = document.createElement("a");
265
- a2.href = anchor.href;
266
- a2.textContent = anchor.textContent ? anchor.textContent.trim() : "";
267
- a2.className = ${clsLiteral(NAV_MENU_PLAIN)};
268
- if (anchor.getAttribute("aria-current") === "page") {
269
- a2.className += ${clsAppend(NAV_MENU_PLAIN_ACTIVE_SUFFIX)};
270
- }
271
- li2.appendChild(a2);
272
- moreMenu.appendChild(li2);
273
- }
274
- }
275
- }
276
-
277
- moreToggle.addEventListener("click", function () {
278
- var isOpen = !moreMenu.classList.contains("hidden");
279
- moreMenu.classList.toggle("hidden", isOpen);
280
- moreToggle.setAttribute("aria-expanded", String(!isOpen));
281
- }, { signal: controller.signal });
282
-
283
- document.addEventListener("click", function (e) {
284
- if (!moreContainer.contains(e.target)) {
285
- moreMenu.classList.add("hidden");
286
- moreToggle.setAttribute("aria-expanded", "false");
287
- }
288
- }, { signal: controller.signal });
289
-
290
- document.addEventListener("keydown", function (e) {
291
- if (e.key !== "Escape") return;
292
- if (!moreMenu.classList.contains("hidden")) {
293
- moreMenu.classList.add("hidden");
294
- moreToggle.setAttribute("aria-expanded", "false");
295
- moreToggle.focus();
296
- return;
297
- }
298
- var active = document.activeElement;
299
- var dropdown = active && active.closest ? active.closest("[data-nav-item-dropdown]") : null;
300
- if (dropdown && active && active.blur) {
301
- active.blur();
302
- }
303
- }, { signal: controller.signal });
304
-
305
- var dropdowns = nav.querySelectorAll("[data-nav-item-dropdown]");
306
- dropdowns.forEach(function (dd) {
307
- var trigger = dd.querySelector(":scope > a");
308
- if (!trigger) return;
309
- function setExpanded(v) {
310
- trigger.setAttribute("aria-expanded", String(v));
311
- }
312
- dd.addEventListener("mouseenter", function () { setExpanded(true); }, { signal: controller.signal });
313
- dd.addEventListener("mouseleave", function () { setExpanded(false); }, { signal: controller.signal });
314
- dd.addEventListener("focusin", function () { setExpanded(true); }, { signal: controller.signal });
315
- dd.addEventListener("focusout", function (e) {
316
- if (!dd.contains(e.relatedTarget)) {
317
- setExpanded(false);
318
- }
319
- }, { signal: controller.signal });
320
- });
321
-
322
- var ro = new ResizeObserver(update);
323
- ro.observe(nav);
324
- controller.signal.addEventListener("abort", function () { ro.disconnect(); });
325
-
326
- document.fonts.ready.then(update);
327
-
328
- update();
329
-
330
- cleanupNavOverflow = function () { controller.abort(); };
331
- }
332
-
333
- initNavOverflow();
334
- document.addEventListener(${JSON.stringify(AFTER_NAVIGATE_EVENT)}, initNavOverflow);
335
- })();`;
32
+ //
33
+ // FROZEN (zudolab/zudo-doc#3534, epic #3533): the script body used to be
34
+ // assembled here, at module-eval time, from three `Function.prototype.toString()`
35
+ // embeddings (`CURRENT_PATH_SCRIPT_PRELUDE`, `pathMatchesNavPath`,
36
+ // `computeActiveNavPath`) plus splice formatters over the twelve
37
+ // `nav-class-tokens.ts` arrays. That made the emitted bytes depend on the
38
+ // CONSUMING bundler (zudolab/zudo-doc#3502), so no CSP hash could be pinned
39
+ // against a stable value. The assembly logic now lives in
40
+ // `scripts/gen-nav-overflow-script.mjs`, which freezes it ONCE at package
41
+ // build time into the committed `./nav-overflow-generated-script.ts` literal
42
+ // re-exported below. Regenerate via
43
+ // `pnpm --filter @takazudo/zudo-doc gen:nav-overflow-script` after editing
44
+ // any of the four source files it reads from (see the generator's header
45
+ // comment for the list); the vitest guard at
46
+ // `src/header/__tests__/nav-overflow-script.test.ts` proves the committed
47
+ // literal still matches a fresh regeneration.
48
+ //
49
+ // EJECTED COPIES (`zudo-doc eject header`): the generator is NOT shipped, so
50
+ // in an ejected tree the re-exported literal is permanently frozen — editing
51
+ // the ejected `./nav-class-tokens.ts` or `./nav-active.ts` changes the SSR
52
+ // markup (header.tsx imports them live) but NOT this client script, breaking
53
+ // the SSR ↔ runtime class lockstep those files exist to guarantee. To change
54
+ // the client script in an ejected copy, edit the literal in
55
+ // `./nav-overflow-generated-script.ts` directly (it is plain JS in a string)
56
+ // and keep it in step with your token edits by hand.
57
+ export { NAV_OVERFLOW_SCRIPT } from "./nav-overflow-generated-script.js";
@@ -7,10 +7,22 @@
7
7
  import { useState, useEffect } from "preact/hooks";
8
8
  // After zudolab/zudo-doc#1335 the host components pull lifecycle event names
9
9
  // from the v2 transitions module rather than hard-coding `astro:*` literals.
10
- import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
10
+ // `ensureNestedIslandPropsRefresh` is imported through the barrel (not the
11
+ // deep `./nested-island-props-refresh.js` path) on purpose: eject rewrites
12
+ // EVERY `../transitions/<anything>.js` import to the single specifier
13
+ // `@takazudo/zudo-doc/transitions`, so two distinct relative imports would
14
+ // collapse into duplicate import statements in an ejected copy.
15
+ import { AFTER_NAVIGATE_EVENT, ensureNestedIslandPropsRefresh } from "../transitions/index.js";
11
16
  import { SidebarTree } from "../sidebar-tree-island/index.js";
12
17
  import type { SidebarNavNode, SidebarRootMenuItem, SidebarLocaleLink } from "../sidebar/types.js";
13
18
 
19
+ // This island lives INSIDE the persisted `<header>`, so a same-locale swap
20
+ // lifts it verbatim and would re-mount it from the previous page's serialized
21
+ // props (zudolab/zudo-doc#3525). The refresh has to outlive the island's own
22
+ // mount/unmount cycle across a swap, so it is installed at document lifetime
23
+ // here rather than from an effect. SSR evaluation is a safe no-op.
24
+ ensureNestedIslandPropsRefresh();
25
+
14
26
  const cx = (...classes: Array<string | false | null | undefined>) =>
15
27
  classes.filter(Boolean).join(" ");
16
28
 
@@ -18,6 +18,7 @@ import { smartBreakToHtml } from "../smart-break/index.js";
18
18
  import { AFTER_NAVIGATE_EVENT, BEFORE_NAVIGATE_EVENT } from "../transitions/index.js";
19
19
  import { filterTree } from "../sidebar-filter/index.js";
20
20
  import { findActiveSlug, normalizePath } from "../sidebar-active-slug/index.js";
21
+ import { CURRENT_PATH_DATASET_KEY, readCurrentPath } from "../current-path/index.js";
21
22
  import { ensureSidebarScrollPreserve } from "./sidebar-scroll-preserve.js";
22
23
 
23
24
  // The persisted aside can transiently tear down and re-mount its SidebarTree
@@ -61,38 +62,54 @@ function saveOpenSet(set: Set<string>) {
61
62
  }
62
63
 
63
64
  /**
64
- * Derive the active slug from the current document URL. Used as a hydration-
65
- * time fallback when the parent island does not forward `currentSlug` through
66
- * its prop boundary, and at every View Transition to keep the highlight in
67
- * sync.
65
+ * Derive the active slug from an explicit current-route input. Resolution
66
+ * order is owned by {@link readCurrentPath}: the `pathname` argument, then the
67
+ * `data-zd-current-path` override, then `window.location.pathname`. Used as a
68
+ * hydration-time fallback when the parent island does not forward
69
+ * `currentSlug` through its prop boundary, and at every View Transition to
70
+ * keep the highlight in sync.
71
+ *
72
+ * Returns `undefined` both when no pathname is resolvable AND when the
73
+ * resolved pathname matches no route (e.g. the literal "srcdoc") — callers
74
+ * must treat `undefined` as "no update," preserving whatever active slug is
75
+ * already set rather than clearing it.
68
76
  */
69
- function deriveActiveSlugFromUrl(nodes: SidebarNavNode[]): string | undefined {
70
- if (typeof window === "undefined") return undefined;
71
- const pathname = normalizePath(window.location.pathname);
72
- return findActiveSlug(nodes, pathname);
77
+ function deriveActiveSlug(nodes: SidebarNavNode[], pathname?: string): string | undefined {
78
+ // An empty-string `pathname` must fall through to the live sources instead
79
+ // of silently disabling derivation — SidebarWithDefaults defaults its own
80
+ // currentPath to "", so "" is the natural absent shape.
81
+ const resolved = readCurrentPath(CURRENT_PATH_DATASET_KEY, pathname);
82
+ if (!resolved) return undefined;
83
+ return findActiveSlug(nodes, normalizePath(resolved));
73
84
  }
74
85
 
75
86
  /**
76
87
  * Track the current active slug, updating on View Transition navigations.
77
88
  *
78
89
  * The initial-state initialiser prefers the SSR-supplied `initial` prop, but
79
- * falls back to deriving the slug from `window.location.pathname` when the
80
- * prop is missing.
90
+ * falls back to `deriveActiveSlug` (explicit `currentPath` input, then the
91
+ * override/location fallbacks) when the prop is missing.
81
92
  */
82
- function useActiveSlug(nodes: SidebarNavNode[], initial?: string): string | undefined {
93
+ function useActiveSlug(nodes: SidebarNavNode[], initial?: string, currentPath?: string): string | undefined {
83
94
  const [slug, setSlug] = useState<string | undefined>(() =>
84
- initial !== undefined ? initial : deriveActiveSlugFromUrl(nodes),
95
+ initial !== undefined ? initial : deriveActiveSlug(nodes, currentPath),
85
96
  );
86
97
 
87
98
  useEffect(() => {
88
- const update = () => {
89
- const found = deriveActiveSlugFromUrl(nodes);
99
+ const update = (pathname?: string) => {
100
+ const found = deriveActiveSlug(nodes, pathname);
90
101
  if (found !== undefined) setSlug(found);
91
102
  };
92
- update();
93
- document.addEventListener(AFTER_NAVIGATE_EVENT, update);
94
- return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, update);
95
- }, [nodes]);
103
+ // The static `currentPath` prop is an SSR-serialized value for the page the
104
+ // island first hydrated on — valid at hydration time only. Post-navigation
105
+ // updates must re-read the LIVE sources (dataset override, then
106
+ // location.pathname); recomputing from the frozen prop would pin the
107
+ // highlight to the first-loaded page across client-router navigations.
108
+ const onNavigate = () => update();
109
+ update(currentPath);
110
+ document.addEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
111
+ return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
112
+ }, [nodes, currentPath]);
96
113
 
97
114
  return slug;
98
115
  }
@@ -143,6 +160,13 @@ function RootMenuItemEntry({ item }: { item: SidebarRootMenuItem }) {
143
160
  export interface SidebarTreeProps {
144
161
  nodes: SidebarNavNode[];
145
162
  currentSlug?: string;
163
+ /**
164
+ * Explicit current-route override, checked before the
165
+ * `data-zd-current-path` dataset override and `window.location.pathname`
166
+ * when deriving the active slug on hydration and at every View Transition.
167
+ * See `deriveActiveSlug` (zudolab/zudo-doc#3398).
168
+ */
169
+ currentPath?: string;
146
170
  rootMenuItems?: SidebarRootMenuItem[];
147
171
  backToMenuLabel?: string;
148
172
  localeLinks?: SidebarLocaleLink[];
@@ -171,8 +195,8 @@ function SidebarFooter({ links, themeDefaultMode }: { links?: SidebarLocaleLink[
171
195
  );
172
196
  }
173
197
 
174
- export function SidebarTree({ nodes, currentSlug, rootMenuItems, backToMenuLabel, localeLinks, themeDefaultMode }: SidebarTreeProps) {
175
- const activeSlug = useActiveSlug(nodes, currentSlug);
198
+ export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, backToMenuLabel, localeLinks, themeDefaultMode }: SidebarTreeProps) {
199
+ const activeSlug = useActiveSlug(nodes, currentSlug, currentPath);
176
200
  const [query, setQuery] = useState("");
177
201
  const [showingRootMenu, setShowingRootMenu] = useState(false);
178
202
  const filterRef = useRef<HTMLInputElement>(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "5.5.3",
3
+ "version": "5.7.0",
4
4
  "type": "module",
5
5
  "description": "zudo-doc framework primitives layer that sits on top of zfb's engine — sidebar, theme, TOC, breadcrumb, layouts, head injection, View Transitions, SSR-skip wrappers (per ADR-003).",
6
6
  "license": "MIT",
@@ -239,6 +239,10 @@
239
239
  "types": "./dist/catalog.d.ts",
240
240
  "default": "./dist/catalog.js"
241
241
  },
242
+ "./site-schema": {
243
+ "types": "./dist/site-schema/index.d.ts",
244
+ "default": "./dist/site-schema/index.js"
245
+ },
242
246
  "./theme.css": "./dist/theme.css",
243
247
  "./safelist.css": "./dist/safelist.css",
244
248
  "./content.css": "./dist/content.css",
@@ -553,6 +557,10 @@
553
557
  "types": "./dist/sidebar-active-slug/index.d.ts",
554
558
  "default": "./dist/sidebar-active-slug/index.js"
555
559
  },
560
+ "./current-path": {
561
+ "types": "./dist/current-path/index.d.ts",
562
+ "default": "./dist/current-path/index.js"
563
+ },
556
564
  "./i18n-defaults": {
557
565
  "types": "./dist/i18n-defaults/index.d.ts",
558
566
  "default": "./dist/i18n-defaults/index.js"
@@ -609,11 +617,11 @@
609
617
  "CHANGELOG.md"
610
618
  ],
611
619
  "peerDependencies": {
612
- "@takazudo/zdtp": "^0.4.10",
613
- "@takazudo/zfb": "^2.5.2",
614
- "@takazudo/zfb-md-wasm": "^2.5.2",
615
- "@takazudo/zfb-runtime": "^2.5.2",
616
- "@takazudo/zudo-doc-history-server": "^5.5.2",
620
+ "@takazudo/zdtp": "^0.4.11",
621
+ "@takazudo/zfb": "^2.7.1",
622
+ "@takazudo/zfb-md-wasm": "^2.7.1",
623
+ "@takazudo/zfb-runtime": "^2.7.1",
624
+ "@takazudo/zudo-doc-history-server": "^5.5.3",
617
625
  "diff": "^8.0.0",
618
626
  "katex": "^0.16.0",
619
627
  "preact": "^10.29.1",
@@ -647,14 +655,15 @@
647
655
  "tsx": "^4.21.0"
648
656
  },
649
657
  "devDependencies": {
650
- "@takazudo/zfb": "2.5.2",
651
- "@takazudo/zfb-md-wasm": "2.5.2",
652
- "@takazudo/zfb-runtime": "2.5.2",
658
+ "@takazudo/zfb": "2.7.1",
659
+ "@takazudo/zfb-md-wasm": "2.7.1",
660
+ "@takazudo/zfb-runtime": "2.7.1",
653
661
  "@types/fs-extra": "^11.0.4",
654
662
  "@types/minimist": "^1.2.5",
655
663
  "@types/node": "^25.3.5",
656
664
  "@types/pluralize": "^0.0.33",
657
665
  "@types/string-similarity": "^4.0.2",
666
+ "esbuild": ">=0.28.1",
658
667
  "happy-dom": "^20.10.6",
659
668
  "npm-run-all2": "^7.0.2",
660
669
  "preact": "^10.29.1",
@@ -663,15 +672,19 @@
663
672
  "typescript": "^5.0.0",
664
673
  "vitest": "^4.1.0",
665
674
  "zod": "^4.3.6",
666
- "@takazudo/zudo-doc-history-server": "5.5.3"
675
+ "@takazudo/zudo-doc-history-server": "5.7.0"
667
676
  },
668
677
  "scripts": {
669
- "build": "tsup && tsc -p tsconfig.build.json",
670
- "predev": "node ../../scripts/ensure-workspace-build.mjs",
678
+ "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
679
+ "gen:nav-overflow-script": "node scripts/gen-nav-overflow-script.mjs",
680
+ "build": "node scripts/gen-search-widget-script.mjs && node scripts/gen-nav-overflow-script.mjs && tsup && tsc -p tsconfig.build.json",
681
+ "predev": "node ../../scripts/ensure-workspace-build.mjs && node scripts/gen-search-widget-script.mjs && node scripts/gen-nav-overflow-script.mjs",
671
682
  "// dev": "Do NOT add `--continue-on-error` here. It stops a fatal watcher exit from cascading, but the dead watcher then fails silently — a dead dev:dts leaves dist/*.d.ts frozen while the JS keeps updating, which typechecks cleanly against stale types instead of failing loudly like #3113's absent declarations did. See the #3129 section in CLAUDE.md.",
672
683
  "dev": "run-p dev:js dev:dts",
673
684
  "dev:js": "tsup --watch",
674
685
  "dev:dts": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
686
+ "// check:prepack-contract": "check:search-widget-drift and check:nav-overflow-drift run here (not just b4push/CI) because `prepare` regenerates BOTH frozen literals unconditionally — without a prepack gate, packing from a tree where a source changed but a literal was never re-committed would silently publish new script bytes and break every consumer's pinned CSP hash (#3534/#3535 for nav-overflow; #3540 extended the same gate to search-widget, the exact failure class the freezes exist to prevent).",
687
+ "check:prepack-contract": "pnpm --dir ../.. check:search-widget-drift && pnpm --dir ../.. check:nav-overflow-drift && pnpm --dir ../.. gen:changelog && node scripts/check-theme-css.mjs && node scripts/check-safelist.mjs && node scripts/check-content-css.mjs && node scripts/check-page-loading-css.mjs && node scripts/check-features-css.mjs && node scripts/check-theme-packs.mjs && node scripts/check-catalog.mjs && node scripts/check-site-schema.mjs && node scripts/check-plugins.mjs && node scripts/check-plugin-resolution.mjs && node scripts/check-eject-sources.mjs && node scripts/check-routes-src.mjs && node scripts/check-shim-artifacts.mjs && node scripts/check-virtual-modules.mjs && node bin/gen-component-tokens.mjs --check",
675
688
  "test:plugin-resolution": "node scripts/check-plugin-resolution.mjs",
676
689
  "test": "vitest run --config vitest.config.ts",
677
690
  "test:slow": "vitest run --config vitest.slow.config.ts",
@@ -13,7 +13,7 @@
13
13
  import { routeCtx } from "./_context.js";
14
14
  import { createChrome } from "@takazudo/zudo-doc/chrome";
15
15
  import { DocHistory } from "@takazudo/zudo-doc/doc-history";
16
- import type { DocNavNode } from "./_docs-helpers.js";
16
+ import type { DocNavNode } from "@takazudo/zudo-doc/site-schema";
17
17
  // Imported via the BARE published subpath rather than a relative
18
18
  // `../chrome-bindings.js`. This file is copied into consumer projects by the
19
19
  // routes-src mechanism (`scripts/copy-routes-src.mjs`), which rewrites relative
@@ -27,6 +27,10 @@ import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
27
27
  // ("Host-callables channel — chromeBindingsModule"). Not present on disk; the
28
28
  // package ships ambient typings for it (`routes/_virtual.d.ts`).
29
29
  import { chromeBindings } from "virtual:zudo-doc-chrome-bindings";
30
+ // Routes-only configured design-token-panel island (#3396) — the ONLY importer
31
+ // of `virtual:zudo-doc-design-token-panel-config` in the package. Static, for
32
+ // the same island-scanner reason as `DocHistory` above.
33
+ import { ConfiguredDesignTokenPanelBootstrap } from "./_design-token-panel-bootstrap.js";
30
34
 
31
35
  // Island-scanner contract (load-bearing): the injected doc routes reach the real
32
36
  // DocHistory client island ONLY through this static import → `createChrome`
@@ -57,15 +61,23 @@ import { chromeBindings } from "virtual:zudo-doc-chrome-bindings";
57
61
  // the way the static `DocHistory` import above is (the virtual re-export sits
58
62
  // outside zfb's static-import scanner reachability graph) — see the ADR.
59
63
  //
60
- // `DesignTokenPanelBootstrap` (#2658) is NOT threaded here: unlike DocHistory
61
- // (whose derive-level default is a no-op stub), the package-default island IS
62
- // the derive-level default (`chrome/derive.tsx`'s `deriveBodyEndIslands`,
63
- // gate-2 fix from the Wave-5 confirm #2659) — so this shim, the locked-manifest
64
- // self-contained doc stub (#2653), and every other bare `createChrome` caller
65
- // all get it without explicit wiring. Scanner reachability holds through the
66
- // static chain route → this shim → `createChrome` → `chrome/derive` →
67
- // `design-token-panel-bootstrap`.
64
+ // `DesignTokenPanelBootstrap` IS threaded here since #3396, but only to swap
65
+ // the BUILDER — the mount, the settings gate, and the slot default all still
66
+ // belong to `chrome/derive.tsx`'s `deriveBodyEndIslands` (gate-2 fix from the
67
+ // Wave-5 confirm #2659), so the locked-manifest self-contained doc stub (#2653)
68
+ // and every other bare `createChrome` caller keep getting the package-default
69
+ // island with no explicit wiring. On THESE injected routes the configured
70
+ // wrapper wins, which is what carries a host's
71
+ // `settings.designTokenPanelConfigModule` through. Scanner reachability holds
72
+ // through the static chain route → this shim → `_design-token-panel-bootstrap`.
73
+ //
74
+ // It is spread BEFORE `...chromeBindings` (unlike `DocHistory`, which is spread
75
+ // after): a host's own `DesignTokenPanelBootstrap` slot value must still win,
76
+ // exactly as it did when the derive-level default was the only package wiring.
68
77
  const chrome = createChrome(routeCtx, {
78
+ ...defineChromeBindings({
79
+ DesignTokenPanelBootstrap: ConfiguredDesignTokenPanelBootstrap,
80
+ }),
69
81
  ...chromeBindings,
70
82
  ...defineChromeBindings({ DocHistory }),
71
83
  });