@takazudo/zudo-doc 5.6.0 → 5.8.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to `@takazudo/zudo-doc` are documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and release notes are generated from the changelog MDX pages.
6
6
 
7
+ ## [5.8.0] - 2026-08-20
8
+
9
+ ### Features
10
+
11
+ - Added strict anchor validation to the showcase and generated-project link checkers, including source-first validation for scaffolded sites. (`b50bd9aac`, `59127e7fe`)
12
+ - Added a published-version guard that prevents release scaffolds from pinning first-party packages that are not yet available from npm. (`4bdc37132`, `8c234c08e`)
13
+ - Shipped a complete nav-overflow regeneration path with ejected headers, preserving stable CSP bytes while allowing local nav behavior and class-token customization. (`1b3d501c3`, `721ff37d3`)
14
+
15
+ ### Bug Fixes
16
+
17
+ - Made nested island props preservation explicit with `data-zd-props-preserve`, preventing opted-out islands from receiving props updates or remount flags during client navigation. (`e75cff084`)
18
+
19
+ ### Other Changes
20
+
21
+ - Strengthened `docTags` regression coverage so an unselected feature cannot emit either a `true` or `false` config field. (`37dec787c`)
22
+ - Updated `@takazudo/zdtp` to 0.4.12 across the package, showcase, and generated scaffold.
23
+
24
+ ## [5.7.0] - 2026-08-20
25
+
26
+ ### Features
27
+
28
+ - `NAV_OVERFLOW_SCRIPT` is now shipped as a committed, drift-gated literal generated at package build time — its bytes no longer depend on the consumer's bundler, so one published CSP inline-script hash covers every rendering. The frozen output is byte-identical to the previous render-time assembly, so no hash changes with this release (93865aac)
29
+ - A `check:nav-overflow-drift` guard now runs in b4push (step 14 of the 29-step suite), CI, the b4push/CI parity manifest, and `prepack`, so any future change to the script's sources fails loudly instead of silently shifting consumer CSP hashes (a9440c2e)
30
+
31
+ ### Bug Fixes
32
+
33
+ - The mobile drawer no longer keeps a stale section tree and active marker after a same-locale cross-section soft navigation: nested-island `data-props` inside persisted elements are refreshed at the `zfb:before-swap` seam, generalizing zfb's top-level persisted-island refresh. Locale-toggle behavior and header persistence are unchanged (9dd1b77c)
34
+ - Islands whose dynamic import is still in flight across a swap now remount from the refreshed props (`data-zfb-island-remount` is set on change), closing a race where the drawer could mount from the pre-navigation snapshot (797e9a8c)
35
+ - `prepack` now also gates on `check:search-widget-drift`, so packing from a drifted tree can no longer publish CSP-breaking `SEARCH_WIDGET_SCRIPT` bytes (5103feb3)
36
+
37
+ ### Other Changes
38
+
39
+ - New mobile cross-section soft-navigation e2e regression spec for the drawer (smoke fixture), with shared drawer helpers (5ac8f536)
40
+ - Route-injection parity snapshots re-baselined with full attribution; the file now documents that a further unexplained hash move must be treated as live non-determinism and diagnosed rather than re-pinned (7a607cd5)
41
+ - Testing docs aligned with the test-hardening retiering: fast/slow lanes clarified and post-retiering counts corrected (07fff398)
42
+
43
+ ## [5.6.1] - 2026-08-19
44
+
45
+ ### Bug Fixes
46
+
47
+ - `stripMarkdown` now decodes numeric and common named HTML character references while preserving intraword underscores in identifiers, preventing corrupted plain text in the search index and `llms.txt` output. (634458d2)
48
+
7
49
  ## [5.6.0] - 2026-08-19
8
50
 
9
51
  ### Features
package/README.md CHANGED
@@ -67,6 +67,23 @@ pnpm add @takazudo/zfb-md-wasm
67
67
 
68
68
  Projects scaffolded by `create-zudo-doc` already include it. If you never render `<HtmlPreview>` / `<HighlightedCode>`, you can omit it.
69
69
 
70
+ ## Ejected header customization
71
+
72
+ `zudo-doc eject header` copies a complete frozen-script regeneration path into
73
+ `src/components/zudo-doc/header`. After changing the ejected `nav-active.ts` or
74
+ `nav-class-tokens.ts`, regenerate the local client controller and commit the
75
+ result:
76
+
77
+ ```sh
78
+ node ./src/components/zudo-doc/header/gen-nav-overflow-script.mjs
79
+ ```
80
+
81
+ The command uses the two local customization inputs plus the installed
82
+ package's current-path and page-event inputs. Its `esbuild` transformer is
83
+ provided by `@takazudo/zudo-doc`; no additional project dependency is needed.
84
+ The resulting `nav-overflow-generated-script.ts` stays frozen for stable CSP
85
+ hashes across consumer bundlers.
86
+
70
87
  ## ⚠️ HTML preview iframe sandbox — trust assumption
71
88
 
72
89
  `<HtmlPreview>` / `<HtmlPreviewWrapper>` render their preview inside an `<iframe srcdoc>` whose `sandbox` attribute **defaults** to:
@@ -340,6 +340,15 @@ ${status} from @takazudo/zudo-doc@${installedVersion}
340
340
  `
341
341
  )
342
342
  );
343
+ if (component === "header") {
344
+ console.log(
345
+ pc.bold("Header frozen-script regeneration:") + `
346
+ After editing ${localDir}/nav-active.ts or nav-class-tokens.ts, run:
347
+ node ./${localDir}/gen-nav-overflow-script.mjs
348
+ Commit ${localDir}/nav-overflow-generated-script.ts with your edits.
349
+ `
350
+ );
351
+ }
343
352
  }
344
353
  export {
345
354
  EJECTABLE,
@@ -40,18 +40,21 @@ export declare function pathForMatch(pathWithoutBase: string, lang: string | und
40
40
  * Segment-aware prefix test: nav path `/docs/guides` matches `/docs/guides`
41
41
  * and `/docs/guides/...` but NOT `/docs/guideship`.
42
42
  *
43
- * Exported (not just module-private) so `nav-overflow-script.ts` can embed it
44
- * verbatim via `.toString()` alongside `computeActiveNavPath` — that function
45
- * closes over this one, so a bare `computeActiveNavPath.toString()` embed
46
- * would reference an undefined `pathMatchesNavPath` in the browser (see the
47
- * caution note on `computeActiveNavPath` below). Kept self-contained (no
48
- * outer references) for the same reason.
43
+ * Exported (not just module-private) so `scripts/gen-nav-overflow-script.mjs`
44
+ * can embed it verbatim via `.toString()` alongside `computeActiveNavPath` —
45
+ * that function closes over this one, so a bare `computeActiveNavPath.toString()`
46
+ * embed would reference an undefined `pathMatchesNavPath` in the browser (see
47
+ * the caution note on `computeActiveNavPath` below). Kept self-contained (no
48
+ * outer references) for the same reason. The generator freezes both into the
49
+ * committed `nav-overflow-generated-script.ts` literal at package build time
50
+ * (zudolab/zudo-doc#3534) — this is no longer a live per-module-eval embed.
49
51
  */
50
52
  export declare function pathMatchesNavPath(currentPath: string, navPath: string): boolean;
51
53
  /**
52
54
  * CAUTION (zudolab/zudo-doc#3398): this closes over `pathMatchesNavPath`
53
- * above. `nav-overflow-script.ts` embeds both via `.toString()` (never this
54
- * one alone) so the generated browser script is self-contained.
55
+ * above. `scripts/gen-nav-overflow-script.mjs` embeds both via `.toString()`
56
+ * (never this one alone) so the frozen browser script (`./nav-overflow-generated-script.ts`,
57
+ * zudolab/zudo-doc#3534) is self-contained.
55
58
  */
56
59
  export declare function computeActiveNavPath(navItems: readonly NavItemLike[], pathForMatchValue: string): string | undefined;
57
60
  /**
@@ -0,0 +1,11 @@
1
+ /** Returns the frozen desktop-nav overflow controller IIFE script. NOTE: the
2
+ * vitest drift guard imports buildNavOverflowScript from
3
+ * scripts/gen-nav-overflow-script.mjs (a fresh re-generation) — NEVER from
4
+ * this module: comparing NAV_OVERFLOW_SCRIPT below against this same file's
5
+ * function would be a vacuous self-comparison. */
6
+ export declare function buildNavOverflowScript(): string;
7
+ /** Client-side script string for the desktop header nav overflow controller.
8
+ * See the module header of this generator for the embedding contract; see
9
+ * current-path/index.ts / header/nav-active.ts / header/nav-class-tokens.ts /
10
+ * transitions/page-events.ts for the frozen sources. */
11
+ export declare const NAV_OVERFLOW_SCRIPT: string;
@@ -0,0 +1,302 @@
1
+ function buildNavOverflowScript() {
2
+ return `(function () {
3
+ var cleanupNavOverflow = null;
4
+
5
+ function trimSlashes(p) {
6
+ while (p.length > 1 && p.charAt(p.length - 1) === "/") p = p.slice(0, -1);
7
+ return p || "/";
8
+ }
9
+
10
+ function navPathname(a) {
11
+ try { return trimSlashes(new URL(a.href, location.href).pathname); }
12
+ catch (e) { return ""; }
13
+ }
14
+
15
+ // Explicit current-route override, embedded from current-path/index.ts so
16
+ // this script cannot drift from the three other read sites
17
+ // (zudolab/zudo-doc#3398, #3408).
18
+ var CURRENT_PATH_DATASET_KEY="zdCurrentPath";var readCurrentPath=function readCurrentPath(datasetKey, explicit) {
19
+ const override = typeof document === "undefined" ? void 0 : document.documentElement.dataset[datasetKey];
20
+ return explicit || override || (typeof window === "undefined" ? void 0 : window.location.pathname);
21
+ };
22
+
23
+ // Shared matching core (zudolab/zudo-doc#3398): embedded verbatim from
24
+ // nav-active.ts so this script's longest-match walk cannot drift from the
25
+ // SSR header's own computeActiveNavPath call (header.tsx). computeActiveNavPath
26
+ // closes over pathMatchesNavPath, so both are embedded together.
27
+ var pathMatchesNavPath = function pathMatchesNavPath(currentPath, navPath) {
28
+ if (currentPath === navPath) return true;
29
+ const prefix = navPath.endsWith("/") ? navPath : \`\${navPath}/\`;
30
+ return currentPath.startsWith(prefix);
31
+ };
32
+ var computeActiveNavPath = function computeActiveNavPath(navItems, pathForMatchValue) {
33
+ const allNavPaths = navItems.flatMap((item) => {
34
+ const paths = [item.path];
35
+ if (item.children) {
36
+ paths.push(...item.children.map((child) => child.path));
37
+ }
38
+ return paths;
39
+ });
40
+ return allNavPaths.filter((p) => pathMatchesNavPath(pathForMatchValue, p)).sort((a, b) => b.length - a.length)[0];
41
+ };
42
+
43
+ // Recompute which header nav item is "active" from the CURRENT URL and
44
+ // repaint the highlight. SSR sets the active item on first paint, but the
45
+ // header is persisted across same-locale client-router swaps
46
+ // (data-zfb-transition-persist), so without this the highlight would stay
47
+ // frozen on the page where the header was first rendered. Mirrors the
48
+ // sidebar island's client-side approach (match the current path against
49
+ // each entry's href) and the SSR longest-match + dropdown-parent rules.
50
+ // URL-based: hrefs and the current path both carry the base + locale
51
+ // prefix, so they compare directly without stripping.
52
+ function applyActiveNav() {
53
+ var nav = document.querySelector("[data-header-nav]");
54
+ if (!nav) return;
55
+ var topItems = Array.from(nav.querySelectorAll(":scope > [data-nav-item]"));
56
+ if (topItems.length === 0) return;
57
+
58
+ var cur = trimSlashes(readCurrentPath(CURRENT_PATH_DATASET_KEY));
59
+
60
+ // Build NavItemLike-shaped entries from the live DOM so the shared
61
+ // computeActiveNavPath can do the deepest-match walk \u2014 the same call
62
+ // shape the SSR header uses (matches computeActiveNavPath). A dropdown
63
+ // missing its own top-level anchor is skipped entirely (path "" would
64
+ // otherwise match every current path \u2014 pathMatchesNavPath treats "" as
65
+ // the root "/"), mirroring the parentLink guard used below for the same
66
+ // malformed-markup case.
67
+ var navItems = [];
68
+ topItems.forEach(function (it) {
69
+ var isDropdown = it.hasAttribute("data-nav-item-dropdown");
70
+ var topA = isDropdown ? it.querySelector(":scope > a") : it;
71
+ if (!topA) return;
72
+ var children = [];
73
+ if (isDropdown) {
74
+ it.querySelectorAll(":scope > div a").forEach(function (c) {
75
+ children.push({ path: navPathname(c) });
76
+ });
77
+ }
78
+ navItems.push({ path: navPathname(topA), children: children });
79
+ });
80
+
81
+ var activePath = computeActiveNavPath(navItems, cur) || "";
82
+
83
+ function setTopActive(a, active) {
84
+ if (!a) return;
85
+ if (active) {
86
+ a.classList.add("bg-fg", "text-bg");
87
+ a.classList.remove("text-muted", "hover:text-accent", "hover:underline", "focus:underline", "focus:text-accent");
88
+ a.setAttribute("aria-current", "page");
89
+ } else {
90
+ a.classList.remove("bg-fg", "text-bg");
91
+ a.classList.add("text-muted", "hover:text-accent", "hover:underline", "focus:underline", "focus:text-accent");
92
+ a.removeAttribute("aria-current");
93
+ }
94
+ }
95
+
96
+ topItems.forEach(function (it) {
97
+ var isDropdown = it.hasAttribute("data-nav-item-dropdown");
98
+ var topA = isDropdown ? it.querySelector(":scope > a") : it;
99
+ var topActive = false;
100
+
101
+ if (isDropdown) {
102
+ var parentMatch = !!topA && navPathname(topA) === activePath && activePath !== "";
103
+ var anyChild = false;
104
+ it.querySelectorAll(":scope > div a").forEach(function (c) {
105
+ var childActive = navPathname(c) === activePath && activePath !== "";
106
+ if (childActive) {
107
+ anyChild = true;
108
+ c.setAttribute("data-active", "");
109
+ c.classList.add("font-bold", "text-accent");
110
+ c.classList.remove("text-fg", "hover:text-accent", "focus-visible:text-accent");
111
+ } else {
112
+ c.removeAttribute("data-active");
113
+ c.classList.remove("font-bold", "text-accent");
114
+ c.classList.add("text-fg", "hover:text-accent", "focus-visible:text-accent");
115
+ }
116
+ });
117
+ topActive = parentMatch || anyChild;
118
+ var svg = topA ? topA.querySelector("svg") : null;
119
+ if (svg) {
120
+ if (topActive) { svg.classList.add("text-bg"); svg.classList.remove("text-muted"); }
121
+ else { svg.classList.add("text-muted"); svg.classList.remove("text-bg"); }
122
+ }
123
+ } else {
124
+ topActive = activePath !== "" && navPathname(topA) === activePath;
125
+ }
126
+
127
+ setTopActive(topA, topActive);
128
+ });
129
+ }
130
+
131
+ function initNavOverflow() {
132
+ if (cleanupNavOverflow) cleanupNavOverflow();
133
+
134
+ // Repaint the active highlight for the current URL before measuring /
135
+ // cloning, so the overflow "\xB7\xB7\xB7" menu mirrors the correct active state.
136
+ applyActiveNav();
137
+
138
+ var nav = document.querySelector("[data-header-nav]");
139
+ var moreContainer = document.querySelector("[data-nav-more]");
140
+ var moreMenu = document.querySelector("[data-nav-more-menu]");
141
+ var moreToggle = document.querySelector("[data-nav-more-toggle]");
142
+ if (!nav || !moreContainer || !moreMenu || !moreToggle) return;
143
+
144
+ var items = Array.from(nav.querySelectorAll(":scope > [data-nav-item]"));
145
+ if (items.length === 0) return;
146
+
147
+ var controller = new AbortController();
148
+
149
+ function update() {
150
+ items.forEach(function (el) { el.style.display = ""; });
151
+ moreContainer.style.display = "";
152
+ moreMenu.innerHTML = "";
153
+ moreMenu.classList.add("hidden");
154
+ moreToggle.setAttribute("aria-expanded", "false");
155
+
156
+ var itemWidths = items.map(function (el) { return el.offsetWidth; });
157
+ var moreWidth = moreContainer.offsetWidth;
158
+ var navGap = parseFloat(getComputedStyle(nav).columnGap) || 0;
159
+ var available = nav.clientWidth;
160
+
161
+ if (available <= 0) {
162
+ moreContainer.style.display = "none";
163
+ return;
164
+ }
165
+
166
+ var total = 0;
167
+ for (var i = 0; i < itemWidths.length; i++) {
168
+ total += itemWidths[i] + (i > 0 ? navGap : 0);
169
+ }
170
+
171
+ if (total <= available) {
172
+ moreContainer.style.display = "none";
173
+ return;
174
+ }
175
+
176
+ var used = 0;
177
+ var cutoffIndex = 0;
178
+
179
+ for (var i2 = 0; i2 < items.length; i2++) {
180
+ var w = itemWidths[i2] + (i2 > 0 ? navGap : 0);
181
+ if (used + w > available - moreWidth - navGap) break;
182
+ used += w;
183
+ cutoffIndex = i2 + 1;
184
+ }
185
+
186
+ for (var i3 = cutoffIndex; i3 < items.length; i3++) {
187
+ items[i3].style.display = "none";
188
+ }
189
+
190
+ for (var i4 = cutoffIndex; i4 < items.length; i4++) {
191
+ var el = items[i4];
192
+ var isDropdown = el.hasAttribute("data-nav-item-dropdown");
193
+
194
+ if (isDropdown) {
195
+ var parentLink = el.querySelector(":scope > a");
196
+ var childLinks = el.querySelectorAll(":scope > div a");
197
+ if (parentLink) {
198
+ var li = document.createElement("li");
199
+ var a = document.createElement("a");
200
+ a.href = parentLink.href;
201
+ var parentText = parentLink.textContent ? parentLink.textContent.trim().replace(/\\s+/g, " ") : "";
202
+ a.textContent = parentText;
203
+ 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";
204
+ if (parentLink.getAttribute("aria-current") === "page") {
205
+ a.className += " text-accent";
206
+ }
207
+ li.appendChild(a);
208
+ moreMenu.appendChild(li);
209
+ }
210
+ childLinks.forEach(function (child) {
211
+ var li = document.createElement("li");
212
+ var a = document.createElement("a");
213
+ a.href = child.href;
214
+ a.textContent = child.textContent ? child.textContent.trim() : "";
215
+ var isChildActive = child.hasAttribute("data-active");
216
+ a.className = isChildActive
217
+ ? "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"
218
+ : "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";
219
+ li.appendChild(a);
220
+ moreMenu.appendChild(li);
221
+ });
222
+ } else {
223
+ var anchor = el;
224
+ var li2 = document.createElement("li");
225
+ var a2 = document.createElement("a");
226
+ a2.href = anchor.href;
227
+ a2.textContent = anchor.textContent ? anchor.textContent.trim() : "";
228
+ 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";
229
+ if (anchor.getAttribute("aria-current") === "page") {
230
+ a2.className += " font-bold text-accent";
231
+ }
232
+ li2.appendChild(a2);
233
+ moreMenu.appendChild(li2);
234
+ }
235
+ }
236
+ }
237
+
238
+ moreToggle.addEventListener("click", function () {
239
+ var isOpen = !moreMenu.classList.contains("hidden");
240
+ moreMenu.classList.toggle("hidden", isOpen);
241
+ moreToggle.setAttribute("aria-expanded", String(!isOpen));
242
+ }, { signal: controller.signal });
243
+
244
+ document.addEventListener("click", function (e) {
245
+ if (!moreContainer.contains(e.target)) {
246
+ moreMenu.classList.add("hidden");
247
+ moreToggle.setAttribute("aria-expanded", "false");
248
+ }
249
+ }, { signal: controller.signal });
250
+
251
+ document.addEventListener("keydown", function (e) {
252
+ if (e.key !== "Escape") return;
253
+ if (!moreMenu.classList.contains("hidden")) {
254
+ moreMenu.classList.add("hidden");
255
+ moreToggle.setAttribute("aria-expanded", "false");
256
+ moreToggle.focus();
257
+ return;
258
+ }
259
+ var active = document.activeElement;
260
+ var dropdown = active && active.closest ? active.closest("[data-nav-item-dropdown]") : null;
261
+ if (dropdown && active && active.blur) {
262
+ active.blur();
263
+ }
264
+ }, { signal: controller.signal });
265
+
266
+ var dropdowns = nav.querySelectorAll("[data-nav-item-dropdown]");
267
+ dropdowns.forEach(function (dd) {
268
+ var trigger = dd.querySelector(":scope > a");
269
+ if (!trigger) return;
270
+ function setExpanded(v) {
271
+ trigger.setAttribute("aria-expanded", String(v));
272
+ }
273
+ dd.addEventListener("mouseenter", function () { setExpanded(true); }, { signal: controller.signal });
274
+ dd.addEventListener("mouseleave", function () { setExpanded(false); }, { signal: controller.signal });
275
+ dd.addEventListener("focusin", function () { setExpanded(true); }, { signal: controller.signal });
276
+ dd.addEventListener("focusout", function (e) {
277
+ if (!dd.contains(e.relatedTarget)) {
278
+ setExpanded(false);
279
+ }
280
+ }, { signal: controller.signal });
281
+ });
282
+
283
+ var ro = new ResizeObserver(update);
284
+ ro.observe(nav);
285
+ controller.signal.addEventListener("abort", function () { ro.disconnect(); });
286
+
287
+ document.fonts.ready.then(update);
288
+
289
+ update();
290
+
291
+ cleanupNavOverflow = function () { controller.abort(); };
292
+ }
293
+
294
+ initNavOverflow();
295
+ document.addEventListener("zfb:after-swap", initNavOverflow);
296
+ })();`;
297
+ }
298
+ const NAV_OVERFLOW_SCRIPT = buildNavOverflowScript();
299
+ export {
300
+ NAV_OVERFLOW_SCRIPT,
301
+ buildNavOverflowScript
302
+ };
@@ -1 +1 @@
1
- export declare const NAV_OVERFLOW_SCRIPT: string;
1
+ export { NAV_OVERFLOW_SCRIPT } from "./nav-overflow-generated-script.js";