@takazudo/zudo-doc 5.6.0 → 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.
@@ -54,12 +54,14 @@ export function pathForMatch(
54
54
  * Segment-aware prefix test: nav path `/docs/guides` matches `/docs/guides`
55
55
  * and `/docs/guides/...` but NOT `/docs/guideship`.
56
56
  *
57
- * Exported (not just module-private) so `nav-overflow-script.ts` can embed it
58
- * verbatim via `.toString()` alongside `computeActiveNavPath` — that function
59
- * closes over this one, so a bare `computeActiveNavPath.toString()` embed
60
- * would reference an undefined `pathMatchesNavPath` in the browser (see the
61
- * caution note on `computeActiveNavPath` below). Kept self-contained (no
62
- * outer references) for the same reason.
57
+ * Exported (not just module-private) so `scripts/gen-nav-overflow-script.mjs`
58
+ * can embed it verbatim via `.toString()` alongside `computeActiveNavPath` —
59
+ * that function closes over this one, so a bare `computeActiveNavPath.toString()`
60
+ * embed would reference an undefined `pathMatchesNavPath` in the browser (see
61
+ * the caution note on `computeActiveNavPath` below). Kept self-contained (no
62
+ * outer references) for the same reason. The generator freezes both into the
63
+ * committed `nav-overflow-generated-script.ts` literal at package build time
64
+ * (zudolab/zudo-doc#3534) — this is no longer a live per-module-eval embed.
63
65
  */
64
66
  export function pathMatchesNavPath(currentPath: string, navPath: string): boolean {
65
67
  if (currentPath === navPath) return true;
@@ -69,8 +71,9 @@ export function pathMatchesNavPath(currentPath: string, navPath: string): boolea
69
71
 
70
72
  /**
71
73
  * CAUTION (zudolab/zudo-doc#3398): this closes over `pathMatchesNavPath`
72
- * above. `nav-overflow-script.ts` embeds both via `.toString()` (never this
73
- * one alone) so the generated browser script is self-contained.
74
+ * above. `scripts/gen-nav-overflow-script.mjs` embeds both via `.toString()`
75
+ * (never this one alone) so the frozen browser script (`./nav-overflow-generated-script.ts`,
76
+ * zudolab/zudo-doc#3534) is self-contained.
74
77
  */
75
78
  export function computeActiveNavPath(
76
79
  navItems: readonly NavItemLike[],
@@ -13,11 +13,15 @@
13
13
  //
14
14
  // Tokens are ARRAYS, not whole strings, because the two consumers need
15
15
  // different shapes: `header.tsx` joins them into `class={}` attributes, while
16
- // the script both spreads them into `classList.add/remove(...)` calls and
17
- // joins them into `className` assignments. Keeping them composable per-part
18
- // also lets the overflow-menu recipes (which intentionally differ from the
19
- // header-bar items in padding/weight/base) reuse the shared color tokens
20
- // instead of re-typing them.
16
+ // `scripts/gen-nav-overflow-script.mjs` (zudolab/zudo-doc#3534 — the assembly
17
+ // that used to live in `nav-overflow-script.ts` at module-eval time, now
18
+ // frozen into the committed `nav-overflow-generated-script.ts` literal at
19
+ // package build time) reads these arrays off this module's exports and both
20
+ // spreads them into `classList.add/remove(...)` calls and joins them into
21
+ // `className` assignments. Keeping them composable per-part also lets the
22
+ // overflow-menu recipes (which intentionally differ from the header-bar
23
+ // items in padding/weight/base) reuse the shared color tokens instead of
24
+ // re-typing them.
21
25
 
22
26
  // ── Header-bar top-level item: active-state color toggle ─────────────────
23
27
  // setTopActive() (script) ↔ the top-level <a> in renderNavItem() (SSR).
@@ -0,0 +1,29 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Produced by scripts/gen-nav-overflow-script.mjs (zudolab/zudo-doc#3534,
3
+ // epic #3533) from src/current-path/index.ts (CURRENT_PATH_SCRIPT_PRELUDE),
4
+ // src/header/nav-active.ts (pathMatchesNavPath/computeActiveNavPath,
5
+ // type-stripped), src/header/nav-class-tokens.ts (the twelve class-token
6
+ // arrays), and src/transitions/page-events.ts (AFTER_NAVIGATE_EVENT).
7
+ // Re-run `pnpm --filter @takazudo/zudo-doc gen:nav-overflow-script` (or any
8
+ // build/dev entry point, which already runs it) to regenerate after editing
9
+ // any of those source files.
10
+ //
11
+ // This file is committed to git (mirrors search-widget-script/generated-script.ts,
12
+ // zudolab/zudo-doc#3421 / #3431) — a deliberate departure from this repo's
13
+ // usual gitignored-generated-file convention (routes-src/, virtual-modules.d.ts).
14
+ // Regenerate AND commit the result after editing any of the four source files.
15
+
16
+ /** Returns the frozen desktop-nav overflow controller IIFE script. NOTE: the
17
+ * vitest drift guard imports buildNavOverflowScript from
18
+ * scripts/gen-nav-overflow-script.mjs (a fresh re-generation) — NEVER from
19
+ * this module: comparing NAV_OVERFLOW_SCRIPT below against this same file's
20
+ * function would be a vacuous self-comparison. */
21
+ export function buildNavOverflowScript(): string {
22
+ return "(function () {\n var cleanupNavOverflow = null;\n\n function trimSlashes(p) {\n while (p.length > 1 && p.charAt(p.length - 1) === \"/\") p = p.slice(0, -1);\n return p || \"/\";\n }\n\n function navPathname(a) {\n try { return trimSlashes(new URL(a.href, location.href).pathname); }\n catch (e) { return \"\"; }\n }\n\n // Explicit current-route override, embedded from current-path/index.ts so\n // this script cannot drift from the three other read sites\n // (zudolab/zudo-doc#3398, #3408).\n var CURRENT_PATH_DATASET_KEY=\"zdCurrentPath\";var readCurrentPath=function readCurrentPath(datasetKey, explicit) {\n const override = typeof document === \"undefined\" ? void 0 : document.documentElement.dataset[datasetKey];\n return explicit || override || (typeof window === \"undefined\" ? void 0 : window.location.pathname);\n};\n\n // Shared matching core (zudolab/zudo-doc#3398): embedded verbatim from\n // nav-active.ts so this script's longest-match walk cannot drift from the\n // SSR header's own computeActiveNavPath call (header.tsx). computeActiveNavPath\n // closes over pathMatchesNavPath, so both are embedded together.\n var pathMatchesNavPath = function pathMatchesNavPath(currentPath, navPath) {\n if (currentPath === navPath) return true;\n const prefix = navPath.endsWith(\"/\") ? navPath : `${navPath}/`;\n return currentPath.startsWith(prefix);\n};\n var computeActiveNavPath = function computeActiveNavPath(navItems, pathForMatchValue) {\n const allNavPaths = navItems.flatMap((item) => {\n const paths = [item.path];\n if (item.children) {\n paths.push(...item.children.map((child) => child.path));\n }\n return paths;\n });\n return allNavPaths.filter((p) => pathMatchesNavPath(pathForMatchValue, p)).sort((a, b) => b.length - a.length)[0];\n};\n\n // Recompute which header nav item is \"active\" from the CURRENT URL and\n // repaint the highlight. SSR sets the active item on first paint, but the\n // header is persisted across same-locale client-router swaps\n // (data-zfb-transition-persist), so without this the highlight would stay\n // frozen on the page where the header was first rendered. Mirrors the\n // sidebar island's client-side approach (match the current path against\n // each entry's href) and the SSR longest-match + dropdown-parent rules.\n // URL-based: hrefs and the current path both carry the base + locale\n // prefix, so they compare directly without stripping.\n function applyActiveNav() {\n var nav = document.querySelector(\"[data-header-nav]\");\n if (!nav) return;\n var topItems = Array.from(nav.querySelectorAll(\":scope > [data-nav-item]\"));\n if (topItems.length === 0) return;\n\n var cur = trimSlashes(readCurrentPath(CURRENT_PATH_DATASET_KEY));\n\n // Build NavItemLike-shaped entries from the live DOM so the shared\n // computeActiveNavPath can do the deepest-match walk — the same call\n // shape the SSR header uses (matches computeActiveNavPath). A dropdown\n // missing its own top-level anchor is skipped entirely (path \"\" would\n // otherwise match every current path — pathMatchesNavPath treats \"\" as\n // the root \"/\"), mirroring the parentLink guard used below for the same\n // malformed-markup case.\n var navItems = [];\n topItems.forEach(function (it) {\n var isDropdown = it.hasAttribute(\"data-nav-item-dropdown\");\n var topA = isDropdown ? it.querySelector(\":scope > a\") : it;\n if (!topA) return;\n var children = [];\n if (isDropdown) {\n it.querySelectorAll(\":scope > div a\").forEach(function (c) {\n children.push({ path: navPathname(c) });\n });\n }\n navItems.push({ path: navPathname(topA), children: children });\n });\n\n var activePath = computeActiveNavPath(navItems, cur) || \"\";\n\n function setTopActive(a, active) {\n if (!a) return;\n if (active) {\n a.classList.add(\"bg-fg\", \"text-bg\");\n a.classList.remove(\"text-muted\", \"hover:text-accent\", \"hover:underline\", \"focus:underline\", \"focus:text-accent\");\n a.setAttribute(\"aria-current\", \"page\");\n } else {\n a.classList.remove(\"bg-fg\", \"text-bg\");\n a.classList.add(\"text-muted\", \"hover:text-accent\", \"hover:underline\", \"focus:underline\", \"focus:text-accent\");\n a.removeAttribute(\"aria-current\");\n }\n }\n\n topItems.forEach(function (it) {\n var isDropdown = it.hasAttribute(\"data-nav-item-dropdown\");\n var topA = isDropdown ? it.querySelector(\":scope > a\") : it;\n var topActive = false;\n\n if (isDropdown) {\n var parentMatch = !!topA && navPathname(topA) === activePath && activePath !== \"\";\n var anyChild = false;\n it.querySelectorAll(\":scope > div a\").forEach(function (c) {\n var childActive = navPathname(c) === activePath && activePath !== \"\";\n if (childActive) {\n anyChild = true;\n c.setAttribute(\"data-active\", \"\");\n c.classList.add(\"font-bold\", \"text-accent\");\n c.classList.remove(\"text-fg\", \"hover:text-accent\", \"focus-visible:text-accent\");\n } else {\n c.removeAttribute(\"data-active\");\n c.classList.remove(\"font-bold\", \"text-accent\");\n c.classList.add(\"text-fg\", \"hover:text-accent\", \"focus-visible:text-accent\");\n }\n });\n topActive = parentMatch || anyChild;\n var svg = topA ? topA.querySelector(\"svg\") : null;\n if (svg) {\n if (topActive) { svg.classList.add(\"text-bg\"); svg.classList.remove(\"text-muted\"); }\n else { svg.classList.add(\"text-muted\"); svg.classList.remove(\"text-bg\"); }\n }\n } else {\n topActive = activePath !== \"\" && navPathname(topA) === activePath;\n }\n\n setTopActive(topA, topActive);\n });\n }\n\n function initNavOverflow() {\n if (cleanupNavOverflow) cleanupNavOverflow();\n\n // Repaint the active highlight for the current URL before measuring /\n // cloning, so the overflow \"···\" menu mirrors the correct active state.\n applyActiveNav();\n\n var nav = document.querySelector(\"[data-header-nav]\");\n var moreContainer = document.querySelector(\"[data-nav-more]\");\n var moreMenu = document.querySelector(\"[data-nav-more-menu]\");\n var moreToggle = document.querySelector(\"[data-nav-more-toggle]\");\n if (!nav || !moreContainer || !moreMenu || !moreToggle) return;\n\n var items = Array.from(nav.querySelectorAll(\":scope > [data-nav-item]\"));\n if (items.length === 0) return;\n\n var controller = new AbortController();\n\n function update() {\n items.forEach(function (el) { el.style.display = \"\"; });\n moreContainer.style.display = \"\";\n moreMenu.innerHTML = \"\";\n moreMenu.classList.add(\"hidden\");\n moreToggle.setAttribute(\"aria-expanded\", \"false\");\n\n var itemWidths = items.map(function (el) { return el.offsetWidth; });\n var moreWidth = moreContainer.offsetWidth;\n var navGap = parseFloat(getComputedStyle(nav).columnGap) || 0;\n var available = nav.clientWidth;\n\n if (available <= 0) {\n moreContainer.style.display = \"none\";\n return;\n }\n\n var total = 0;\n for (var i = 0; i < itemWidths.length; i++) {\n total += itemWidths[i] + (i > 0 ? navGap : 0);\n }\n\n if (total <= available) {\n moreContainer.style.display = \"none\";\n return;\n }\n\n var used = 0;\n var cutoffIndex = 0;\n\n for (var i2 = 0; i2 < items.length; i2++) {\n var w = itemWidths[i2] + (i2 > 0 ? navGap : 0);\n if (used + w > available - moreWidth - navGap) break;\n used += w;\n cutoffIndex = i2 + 1;\n }\n\n for (var i3 = cutoffIndex; i3 < items.length; i3++) {\n items[i3].style.display = \"none\";\n }\n\n for (var i4 = cutoffIndex; i4 < items.length; i4++) {\n var el = items[i4];\n var isDropdown = el.hasAttribute(\"data-nav-item-dropdown\");\n\n if (isDropdown) {\n var parentLink = el.querySelector(\":scope > a\");\n var childLinks = el.querySelectorAll(\":scope > div a\");\n if (parentLink) {\n var li = document.createElement(\"li\");\n var a = document.createElement(\"a\");\n a.href = parentLink.href;\n var parentText = parentLink.textContent ? parentLink.textContent.trim().replace(/\\s+/g, \" \") : \"\";\n a.textContent = parentText;\n a.className = \"block px-hsp-md py-vsp-2xs text-small font-bold hover:bg-accent/10 hover:underline focus-visible:underline focus-visible:text-accent text-fg hover:text-accent\";\n if (parentLink.getAttribute(\"aria-current\") === \"page\") {\n a.className += \" text-accent\";\n }\n li.appendChild(a);\n moreMenu.appendChild(li);\n }\n childLinks.forEach(function (child) {\n var li = document.createElement(\"li\");\n var a = document.createElement(\"a\");\n a.href = child.href;\n a.textContent = child.textContent ? child.textContent.trim() : \"\";\n var isChildActive = child.hasAttribute(\"data-active\");\n a.className = isChildActive\n ? \"block pl-hsp-xl pr-hsp-md py-vsp-2xs text-small font-bold text-accent hover:bg-accent/10 hover:underline focus-visible:underline\"\n : \"block pl-hsp-xl pr-hsp-md py-vsp-2xs text-small text-fg hover:bg-accent/10 hover:text-accent hover:underline focus-visible:underline focus-visible:text-accent\";\n li.appendChild(a);\n moreMenu.appendChild(li);\n });\n } else {\n var anchor = el;\n var li2 = document.createElement(\"li\");\n var a2 = document.createElement(\"a\");\n a2.href = anchor.href;\n a2.textContent = anchor.textContent ? anchor.textContent.trim() : \"\";\n a2.className = \"block px-hsp-md py-vsp-2xs text-small hover:bg-accent/10 hover:underline focus-visible:underline focus-visible:text-accent text-fg hover:text-accent\";\n if (anchor.getAttribute(\"aria-current\") === \"page\") {\n a2.className += \" font-bold text-accent\";\n }\n li2.appendChild(a2);\n moreMenu.appendChild(li2);\n }\n }\n }\n\n moreToggle.addEventListener(\"click\", function () {\n var isOpen = !moreMenu.classList.contains(\"hidden\");\n moreMenu.classList.toggle(\"hidden\", isOpen);\n moreToggle.setAttribute(\"aria-expanded\", String(!isOpen));\n }, { signal: controller.signal });\n\n document.addEventListener(\"click\", function (e) {\n if (!moreContainer.contains(e.target)) {\n moreMenu.classList.add(\"hidden\");\n moreToggle.setAttribute(\"aria-expanded\", \"false\");\n }\n }, { signal: controller.signal });\n\n document.addEventListener(\"keydown\", function (e) {\n if (e.key !== \"Escape\") return;\n if (!moreMenu.classList.contains(\"hidden\")) {\n moreMenu.classList.add(\"hidden\");\n moreToggle.setAttribute(\"aria-expanded\", \"false\");\n moreToggle.focus();\n return;\n }\n var active = document.activeElement;\n var dropdown = active && active.closest ? active.closest(\"[data-nav-item-dropdown]\") : null;\n if (dropdown && active && active.blur) {\n active.blur();\n }\n }, { signal: controller.signal });\n\n var dropdowns = nav.querySelectorAll(\"[data-nav-item-dropdown]\");\n dropdowns.forEach(function (dd) {\n var trigger = dd.querySelector(\":scope > a\");\n if (!trigger) return;\n function setExpanded(v) {\n trigger.setAttribute(\"aria-expanded\", String(v));\n }\n dd.addEventListener(\"mouseenter\", function () { setExpanded(true); }, { signal: controller.signal });\n dd.addEventListener(\"mouseleave\", function () { setExpanded(false); }, { signal: controller.signal });\n dd.addEventListener(\"focusin\", function () { setExpanded(true); }, { signal: controller.signal });\n dd.addEventListener(\"focusout\", function (e) {\n if (!dd.contains(e.relatedTarget)) {\n setExpanded(false);\n }\n }, { signal: controller.signal });\n });\n\n var ro = new ResizeObserver(update);\n ro.observe(nav);\n controller.signal.addEventListener(\"abort\", function () { ro.disconnect(); });\n\n document.fonts.ready.then(update);\n\n update();\n\n cleanupNavOverflow = function () { controller.abort(); };\n }\n\n initNavOverflow();\n document.addEventListener(\"zfb:after-swap\", initNavOverflow);\n})();";
23
+ }
24
+
25
+ /** Client-side script string for the desktop header nav overflow controller.
26
+ * See the module header of this generator for the embedding contract; see
27
+ * current-path/index.ts / header/nav-active.ts / header/nav-class-tokens.ts /
28
+ * transitions/page-events.ts for the frozen sources. */
29
+ export const NAV_OVERFLOW_SCRIPT: string = buildNavOverflowScript();
@@ -29,320 +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 { CURRENT_PATH_SCRIPT_PRELUDE } from "../current-path/index.js";
35
- import { computeActiveNavPath, pathMatchesNavPath } from "./nav-active.js";
36
- import {
37
- NAV_CHEVRON_ACTIVE,
38
- NAV_CHEVRON_INACTIVE,
39
- NAV_CHILD_ACTIVE,
40
- NAV_CHILD_INACTIVE,
41
- NAV_MENU_CHILD_ACTIVE,
42
- NAV_MENU_CHILD_INACTIVE,
43
- NAV_MENU_PARENT,
44
- NAV_MENU_PARENT_ACTIVE_SUFFIX,
45
- NAV_MENU_PLAIN,
46
- NAV_MENU_PLAIN_ACTIVE_SUFFIX,
47
- NAV_TOP_ACTIVE,
48
- NAV_TOP_INACTIVE,
49
- } from "./nav-class-tokens.js";
50
-
51
- // The class lists spliced into the script below are the SSR ↔ runtime
52
- // lockstep: they must match the strings header.tsx renders. Both files import
53
- // them from ./nav-class-tokens so they cannot drift (zudolab/zudo-doc#3023).
54
- // The script ships as plain text via dangerouslySetInnerHTML and cannot import
55
- // the arrays at runtime, so these helpers splice the token lists into the
56
- // script string at module-eval (build) time instead.
57
-
58
- // -> `"bg-fg", "text-bg"` — argument list for a classList.add/remove(...) call.
59
- const clsArgs = (tokens: readonly string[]): string =>
60
- tokens.map((token) => JSON.stringify(token)).join(", ");
61
-
62
- // -> `"bg-fg text-bg"` — a single class-string literal for `className = ...`.
63
- const clsLiteral = (tokens: readonly string[]): string =>
64
- JSON.stringify(tokens.join(" "));
65
-
66
- // -> `" font-bold text-accent"` — leading-space append for `className += ...`.
67
- const clsAppend = (tokens: readonly string[]): string =>
68
- JSON.stringify(" " + tokens.join(" "));
69
-
70
- export const NAV_OVERFLOW_SCRIPT = `(function () {
71
- var cleanupNavOverflow = null;
72
-
73
- function trimSlashes(p) {
74
- while (p.length > 1 && p.charAt(p.length - 1) === "/") p = p.slice(0, -1);
75
- return p || "/";
76
- }
77
-
78
- function navPathname(a) {
79
- try { return trimSlashes(new URL(a.href, location.href).pathname); }
80
- catch (e) { return ""; }
81
- }
82
-
83
- // Explicit current-route override, embedded from current-path/index.ts so
84
- // this script cannot drift from the three other read sites
85
- // (zudolab/zudo-doc#3398, #3408).
86
- ${CURRENT_PATH_SCRIPT_PRELUDE}
87
-
88
- // Shared matching core (zudolab/zudo-doc#3398): embedded verbatim from
89
- // nav-active.ts so this script's longest-match walk cannot drift from the
90
- // SSR header's own computeActiveNavPath call (header.tsx). computeActiveNavPath
91
- // closes over pathMatchesNavPath, so both are embedded together.
92
- var pathMatchesNavPath = ${pathMatchesNavPath.toString()};
93
- var computeActiveNavPath = ${computeActiveNavPath.toString()};
94
-
95
- // Recompute which header nav item is "active" from the CURRENT URL and
96
- // repaint the highlight. SSR sets the active item on first paint, but the
97
- // header is persisted across same-locale client-router swaps
98
- // (data-zfb-transition-persist), so without this the highlight would stay
99
- // frozen on the page where the header was first rendered. Mirrors the
100
- // sidebar island's client-side approach (match the current path against
101
- // each entry's href) and the SSR longest-match + dropdown-parent rules.
102
- // URL-based: hrefs and the current path both carry the base + locale
103
- // prefix, so they compare directly without stripping.
104
- function applyActiveNav() {
105
- var nav = document.querySelector("[data-header-nav]");
106
- if (!nav) return;
107
- var topItems = Array.from(nav.querySelectorAll(":scope > [data-nav-item]"));
108
- if (topItems.length === 0) return;
109
-
110
- var cur = trimSlashes(readCurrentPath(CURRENT_PATH_DATASET_KEY));
111
-
112
- // Build NavItemLike-shaped entries from the live DOM so the shared
113
- // computeActiveNavPath can do the deepest-match walk — the same call
114
- // shape the SSR header uses (matches computeActiveNavPath). A dropdown
115
- // missing its own top-level anchor is skipped entirely (path "" would
116
- // otherwise match every current path — pathMatchesNavPath treats "" as
117
- // the root "/"), mirroring the parentLink guard used below for the same
118
- // malformed-markup case.
119
- var navItems = [];
120
- topItems.forEach(function (it) {
121
- var isDropdown = it.hasAttribute("data-nav-item-dropdown");
122
- var topA = isDropdown ? it.querySelector(":scope > a") : it;
123
- if (!topA) return;
124
- var children = [];
125
- if (isDropdown) {
126
- it.querySelectorAll(":scope > div a").forEach(function (c) {
127
- children.push({ path: navPathname(c) });
128
- });
129
- }
130
- navItems.push({ path: navPathname(topA), children: children });
131
- });
132
-
133
- var activePath = computeActiveNavPath(navItems, cur) || "";
134
-
135
- function setTopActive(a, active) {
136
- if (!a) return;
137
- if (active) {
138
- a.classList.add(${clsArgs(NAV_TOP_ACTIVE)});
139
- a.classList.remove(${clsArgs(NAV_TOP_INACTIVE)});
140
- a.setAttribute("aria-current", "page");
141
- } else {
142
- a.classList.remove(${clsArgs(NAV_TOP_ACTIVE)});
143
- a.classList.add(${clsArgs(NAV_TOP_INACTIVE)});
144
- a.removeAttribute("aria-current");
145
- }
146
- }
147
-
148
- topItems.forEach(function (it) {
149
- var isDropdown = it.hasAttribute("data-nav-item-dropdown");
150
- var topA = isDropdown ? it.querySelector(":scope > a") : it;
151
- var topActive = false;
152
-
153
- if (isDropdown) {
154
- var parentMatch = !!topA && navPathname(topA) === activePath && activePath !== "";
155
- var anyChild = false;
156
- it.querySelectorAll(":scope > div a").forEach(function (c) {
157
- var childActive = navPathname(c) === activePath && activePath !== "";
158
- if (childActive) {
159
- anyChild = true;
160
- c.setAttribute("data-active", "");
161
- c.classList.add(${clsArgs(NAV_CHILD_ACTIVE)});
162
- c.classList.remove(${clsArgs(NAV_CHILD_INACTIVE)});
163
- } else {
164
- c.removeAttribute("data-active");
165
- c.classList.remove(${clsArgs(NAV_CHILD_ACTIVE)});
166
- c.classList.add(${clsArgs(NAV_CHILD_INACTIVE)});
167
- }
168
- });
169
- topActive = parentMatch || anyChild;
170
- var svg = topA ? topA.querySelector("svg") : null;
171
- if (svg) {
172
- if (topActive) { svg.classList.add(${clsArgs(NAV_CHEVRON_ACTIVE)}); svg.classList.remove(${clsArgs(NAV_CHEVRON_INACTIVE)}); }
173
- else { svg.classList.add(${clsArgs(NAV_CHEVRON_INACTIVE)}); svg.classList.remove(${clsArgs(NAV_CHEVRON_ACTIVE)}); }
174
- }
175
- } else {
176
- topActive = activePath !== "" && navPathname(topA) === activePath;
177
- }
178
-
179
- setTopActive(topA, topActive);
180
- });
181
- }
182
-
183
- function initNavOverflow() {
184
- if (cleanupNavOverflow) cleanupNavOverflow();
185
-
186
- // Repaint the active highlight for the current URL before measuring /
187
- // cloning, so the overflow "···" menu mirrors the correct active state.
188
- applyActiveNav();
189
-
190
- var nav = document.querySelector("[data-header-nav]");
191
- var moreContainer = document.querySelector("[data-nav-more]");
192
- var moreMenu = document.querySelector("[data-nav-more-menu]");
193
- var moreToggle = document.querySelector("[data-nav-more-toggle]");
194
- if (!nav || !moreContainer || !moreMenu || !moreToggle) return;
195
-
196
- var items = Array.from(nav.querySelectorAll(":scope > [data-nav-item]"));
197
- if (items.length === 0) return;
198
-
199
- var controller = new AbortController();
200
-
201
- function update() {
202
- items.forEach(function (el) { el.style.display = ""; });
203
- moreContainer.style.display = "";
204
- moreMenu.innerHTML = "";
205
- moreMenu.classList.add("hidden");
206
- moreToggle.setAttribute("aria-expanded", "false");
207
-
208
- var itemWidths = items.map(function (el) { return el.offsetWidth; });
209
- var moreWidth = moreContainer.offsetWidth;
210
- var navGap = parseFloat(getComputedStyle(nav).columnGap) || 0;
211
- var available = nav.clientWidth;
212
-
213
- if (available <= 0) {
214
- moreContainer.style.display = "none";
215
- return;
216
- }
217
-
218
- var total = 0;
219
- for (var i = 0; i < itemWidths.length; i++) {
220
- total += itemWidths[i] + (i > 0 ? navGap : 0);
221
- }
222
-
223
- if (total <= available) {
224
- moreContainer.style.display = "none";
225
- return;
226
- }
227
-
228
- var used = 0;
229
- var cutoffIndex = 0;
230
-
231
- for (var i2 = 0; i2 < items.length; i2++) {
232
- var w = itemWidths[i2] + (i2 > 0 ? navGap : 0);
233
- if (used + w > available - moreWidth - navGap) break;
234
- used += w;
235
- cutoffIndex = i2 + 1;
236
- }
237
-
238
- for (var i3 = cutoffIndex; i3 < items.length; i3++) {
239
- items[i3].style.display = "none";
240
- }
241
-
242
- for (var i4 = cutoffIndex; i4 < items.length; i4++) {
243
- var el = items[i4];
244
- var isDropdown = el.hasAttribute("data-nav-item-dropdown");
245
-
246
- if (isDropdown) {
247
- var parentLink = el.querySelector(":scope > a");
248
- var childLinks = el.querySelectorAll(":scope > div a");
249
- if (parentLink) {
250
- var li = document.createElement("li");
251
- var a = document.createElement("a");
252
- a.href = parentLink.href;
253
- var parentText = parentLink.textContent ? parentLink.textContent.trim().replace(/\\s+/g, " ") : "";
254
- a.textContent = parentText;
255
- a.className = ${clsLiteral(NAV_MENU_PARENT)};
256
- if (parentLink.getAttribute("aria-current") === "page") {
257
- a.className += ${clsAppend(NAV_MENU_PARENT_ACTIVE_SUFFIX)};
258
- }
259
- li.appendChild(a);
260
- moreMenu.appendChild(li);
261
- }
262
- childLinks.forEach(function (child) {
263
- var li = document.createElement("li");
264
- var a = document.createElement("a");
265
- a.href = child.href;
266
- a.textContent = child.textContent ? child.textContent.trim() : "";
267
- var isChildActive = child.hasAttribute("data-active");
268
- a.className = isChildActive
269
- ? ${clsLiteral(NAV_MENU_CHILD_ACTIVE)}
270
- : ${clsLiteral(NAV_MENU_CHILD_INACTIVE)};
271
- li.appendChild(a);
272
- moreMenu.appendChild(li);
273
- });
274
- } else {
275
- var anchor = el;
276
- var li2 = document.createElement("li");
277
- var a2 = document.createElement("a");
278
- a2.href = anchor.href;
279
- a2.textContent = anchor.textContent ? anchor.textContent.trim() : "";
280
- a2.className = ${clsLiteral(NAV_MENU_PLAIN)};
281
- if (anchor.getAttribute("aria-current") === "page") {
282
- a2.className += ${clsAppend(NAV_MENU_PLAIN_ACTIVE_SUFFIX)};
283
- }
284
- li2.appendChild(a2);
285
- moreMenu.appendChild(li2);
286
- }
287
- }
288
- }
289
-
290
- moreToggle.addEventListener("click", function () {
291
- var isOpen = !moreMenu.classList.contains("hidden");
292
- moreMenu.classList.toggle("hidden", isOpen);
293
- moreToggle.setAttribute("aria-expanded", String(!isOpen));
294
- }, { signal: controller.signal });
295
-
296
- document.addEventListener("click", function (e) {
297
- if (!moreContainer.contains(e.target)) {
298
- moreMenu.classList.add("hidden");
299
- moreToggle.setAttribute("aria-expanded", "false");
300
- }
301
- }, { signal: controller.signal });
302
-
303
- document.addEventListener("keydown", function (e) {
304
- if (e.key !== "Escape") return;
305
- if (!moreMenu.classList.contains("hidden")) {
306
- moreMenu.classList.add("hidden");
307
- moreToggle.setAttribute("aria-expanded", "false");
308
- moreToggle.focus();
309
- return;
310
- }
311
- var active = document.activeElement;
312
- var dropdown = active && active.closest ? active.closest("[data-nav-item-dropdown]") : null;
313
- if (dropdown && active && active.blur) {
314
- active.blur();
315
- }
316
- }, { signal: controller.signal });
317
-
318
- var dropdowns = nav.querySelectorAll("[data-nav-item-dropdown]");
319
- dropdowns.forEach(function (dd) {
320
- var trigger = dd.querySelector(":scope > a");
321
- if (!trigger) return;
322
- function setExpanded(v) {
323
- trigger.setAttribute("aria-expanded", String(v));
324
- }
325
- dd.addEventListener("mouseenter", function () { setExpanded(true); }, { signal: controller.signal });
326
- dd.addEventListener("mouseleave", function () { setExpanded(false); }, { signal: controller.signal });
327
- dd.addEventListener("focusin", function () { setExpanded(true); }, { signal: controller.signal });
328
- dd.addEventListener("focusout", function (e) {
329
- if (!dd.contains(e.relatedTarget)) {
330
- setExpanded(false);
331
- }
332
- }, { signal: controller.signal });
333
- });
334
-
335
- var ro = new ResizeObserver(update);
336
- ro.observe(nav);
337
- controller.signal.addEventListener("abort", function () { ro.disconnect(); });
338
-
339
- document.fonts.ready.then(update);
340
-
341
- update();
342
-
343
- cleanupNavOverflow = function () { controller.abort(); };
344
- }
345
-
346
- initNavOverflow();
347
- document.addEventListener(${JSON.stringify(AFTER_NAVIGATE_EVENT)}, initNavOverflow);
348
- })();`;
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "5.6.0",
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",
@@ -672,16 +672,19 @@
672
672
  "typescript": "^5.0.0",
673
673
  "vitest": "^4.1.0",
674
674
  "zod": "^4.3.6",
675
- "@takazudo/zudo-doc-history-server": "5.6.0"
675
+ "@takazudo/zudo-doc-history-server": "5.7.0"
676
676
  },
677
677
  "scripts": {
678
678
  "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
679
- "build": "node scripts/gen-search-widget-script.mjs && tsup && tsc -p tsconfig.build.json",
680
- "predev": "node ../../scripts/ensure-workspace-build.mjs && 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",
681
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.",
682
683
  "dev": "run-p dev:js dev:dts",
683
684
  "dev:js": "tsup --watch",
684
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",
685
688
  "test:plugin-resolution": "node scripts/check-plugin-resolution.mjs",
686
689
  "test": "vitest run --config vitest.config.ts",
687
690
  "test:slow": "vitest run --config vitest.slow.config.ts",