@takazudo/zudo-doc 4.4.13 → 5.0.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 (86) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/category-nav/index.d.ts +31 -0
  3. package/dist/category-nav/index.js +15 -3
  4. package/dist/category-tree-nav/index.d.ts +32 -0
  5. package/dist/category-tree-nav/index.js +16 -4
  6. package/dist/chrome/derive.d.ts +32 -3
  7. package/dist/chrome/derive.js +26 -7
  8. package/dist/code-syntax/mermaid-init-script.d.ts +6 -7
  9. package/dist/config.d.ts +22 -5
  10. package/dist/config.js +11 -0
  11. package/dist/content.css +1 -0
  12. package/dist/desktop-toc-toggle-island/index.d.ts +7 -0
  13. package/dist/desktop-toc-toggle-island/index.js +64 -0
  14. package/dist/doc-page-renderer/index.d.ts +12 -1
  15. package/dist/doc-page-renderer/index.js +10 -3
  16. package/dist/doc-page-shell/index.d.ts +13 -0
  17. package/dist/doc-page-shell/index.js +37 -3
  18. package/dist/doclayout/doc-layout.d.ts +14 -0
  19. package/dist/doclayout/doc-layout.js +4 -2
  20. package/dist/features.css +82 -10
  21. package/dist/head-with-defaults/index.js +1 -0
  22. package/dist/header/header.d.ts +1 -1
  23. package/dist/header/header.js +2 -2
  24. package/dist/header/types.d.ts +7 -0
  25. package/dist/header-with-defaults/index.d.ts +2 -0
  26. package/dist/header-with-defaults/index.js +9 -2
  27. package/dist/i18n-version/version-switcher.d.ts +36 -0
  28. package/dist/i18n-version/version-switcher.js +43 -3
  29. package/dist/inline-version-switcher/index.d.ts +18 -0
  30. package/dist/inline-version-switcher/index.js +3 -1
  31. package/dist/mdx-components/index.d.ts +11 -0
  32. package/dist/mdx-components/index.js +4 -4
  33. package/dist/nav-data-prep/index.d.ts +18 -3
  34. package/dist/nav-data-prep/index.js +3 -3
  35. package/dist/plugins/internal/claude-resources/generate.d.ts +11 -0
  36. package/dist/plugins/internal/claude-resources/generate.js +16 -13
  37. package/dist/plugins/internal/claude-resources/index.js +6 -1
  38. package/dist/preset.d.ts +16 -7
  39. package/dist/preset.js +8 -6
  40. package/dist/routes/_context.d.ts +1 -1
  41. package/dist/safelist.css +1 -1
  42. package/dist/settings.d.ts +17 -2
  43. package/dist/site-tree-nav/index.d.ts +21 -0
  44. package/dist/site-tree-nav/index.js +26 -7
  45. package/dist/theme-packs/academia/meta.json +1 -1
  46. package/dist/theme-packs/academia/pack.css +13 -3
  47. package/dist/theme-packs/blueprint/meta.json +1 -1
  48. package/dist/theme-packs/blueprint/pack.css +9 -0
  49. package/dist/theme-packs/broadsheet/meta.json +1 -1
  50. package/dist/theme-packs/broadsheet/pack.css +9 -0
  51. package/dist/theme-packs/drift/meta.json +1 -1
  52. package/dist/theme-packs/drift/pack.css +9 -0
  53. package/dist/theme-packs/fjord/meta.json +1 -1
  54. package/dist/theme-packs/fjord/pack.css +9 -0
  55. package/dist/theme-packs/hearth/meta.json +1 -1
  56. package/dist/theme-packs/hearth/pack.css +9 -0
  57. package/dist/theme-packs/hollow/meta.json +1 -1
  58. package/dist/theme-packs/hollow/pack.css +9 -0
  59. package/dist/theme-packs/matcha/meta.json +1 -1
  60. package/dist/theme-packs/matcha/pack.css +9 -0
  61. package/dist/theme-packs/nocturne/meta.json +1 -1
  62. package/dist/theme-packs/nocturne/pack.css +9 -0
  63. package/dist/theme-packs/observatory/meta.json +1 -1
  64. package/dist/theme-packs/observatory/pack.css +18 -0
  65. package/dist/theme-packs/onyx/meta.json +1 -1
  66. package/dist/theme-packs/onyx/pack.css +9 -0
  67. package/dist/theme-packs/sakura/meta.json +1 -1
  68. package/dist/theme-packs/sakura/pack.css +9 -0
  69. package/dist/theme-packs/sumi/meta.json +1 -1
  70. package/dist/theme-packs/sumi/pack.css +9 -0
  71. package/dist/theme-packs/timberline/meta.json +1 -1
  72. package/dist/theme-packs/timberline/pack.css +9 -0
  73. package/dist/theme-packs-registry/validator.js +38 -6
  74. package/dist/toc-prepaint/index.d.ts +60 -0
  75. package/dist/toc-prepaint/index.js +44 -0
  76. package/dist/url-helpers/index.d.ts +1 -1
  77. package/dist/url-helpers/index.js +2 -2
  78. package/dist/version-availability/index.d.ts +75 -0
  79. package/dist/version-availability/index.js +56 -0
  80. package/dist/versions-page/index.d.ts +9 -1
  81. package/dist/versions-page/index.js +6 -2
  82. package/eject/header/header.tsx +3 -2
  83. package/eject/header/types.ts +7 -0
  84. package/package.json +17 -10
  85. package/tsconfig.base.json +2 -1
  86. package/zfb-config-shim.d.ts +22 -204
@@ -3,7 +3,11 @@ import { createDocPageShell } from "../doc-page-shell/index.js";
3
3
  import { createDocContentHeader } from "../doc-content-header/index.js";
4
4
  import { createDocMetainfoArea } from "../doc-metainfo-area/index.js";
5
5
  import { createDocHistoryArea } from "../doc-history-area/index.js";
6
- import { deriveMdxComponents, deriveInlineVersionSwitcher } from "../chrome/derive.js";
6
+ import {
7
+ deriveMdxComponents,
8
+ deriveInlineVersionSwitcher,
9
+ deriveGetUnavailableVersions
10
+ } from "../chrome/derive.js";
7
11
  import { assertChromeContext } from "../chrome/assert-chrome-context.js";
8
12
  function createRenderDocPage(ctx) {
9
13
  assertChromeContext(ctx, "createRenderDocPage");
@@ -14,8 +18,10 @@ function createRenderDocPage(ctx) {
14
18
  const toRouteSlug = ctx.toRouteSlug;
15
19
  const createMdxComponents = deriveMdxComponents(ctx).createMdxComponentsBound;
16
20
  const t = ctx.t;
21
+ const getUnavailableVersions = deriveGetUnavailableVersions(ctx);
17
22
  const buildInlineVersionSwitcher = deriveInlineVersionSwitcher(
18
- ctx
23
+ ctx,
24
+ getUnavailableVersions
19
25
  );
20
26
  const DocPageShell = createDocPageShell(ctx);
21
27
  const DocContentHeader = createDocContentHeader(
@@ -29,7 +35,7 @@ function createRenderDocPage(ctx) {
29
35
  const slug = props.kind === "autoIndex" ? props.autoIndex.slug : props.entry.data.slug ?? toRouteSlug(props.entry.slug);
30
36
  const title = props.kind === "autoIndex" ? props.autoIndex.label : props.entry.data.title;
31
37
  const description = props.kind === "autoIndex" ? props.autoIndex.description : props.entry.data.description;
32
- const components = createMdxComponents(locale);
38
+ const components = createMdxComponents(locale, version?.slug);
33
39
  const autoIndexChildren = props.kind === "autoIndex" ? version ? props.autoIndex.children.filter((c) => c.hasPage || c.children.length > 0) : props.autoIndex.children.filter((c) => c.hasPage || c.children.length > 0).map((c) => ({
34
40
  ...c,
35
41
  href: c.href ?? docsUrl(c.slug, locale)
@@ -71,6 +77,7 @@ function createRenderDocPage(ctx) {
71
77
  currentPath,
72
78
  currentVersion: version?.slug,
73
79
  versionSwitcher: buildInlineVersionSwitcher(slug, locale, version?.slug),
80
+ unavailableVersions: getUnavailableVersions(slug, locale),
74
81
  versionBanner: versionBannerType,
75
82
  versionBannerLatestUrl,
76
83
  versionBannerLabels,
@@ -60,6 +60,19 @@ export interface DocPageShellProps {
60
60
  currentVersion?: string;
61
61
  /** Inline version switcher VNode for the breadcrumb right-slot. */
62
62
  versionSwitcher: ComponentChildren;
63
+ /**
64
+ * This page's unavailable-version slugs, straight from
65
+ * `getUnavailableVersions(slug, locale)` (`../version-availability`) —
66
+ * NOT re-derived here. Serialized onto `<article>` via
67
+ * `serializeUnavailableVersions` so a same-locale SPA navigation rewire
68
+ * script (#3244) can read the per-page availability set after the
69
+ * persisted header's own SSR attributes go stale. `undefined` (no
70
+ * availability data — e.g. versioning not configured) renders NO
71
+ * attribute at all; an empty set renders the attribute with an empty
72
+ * value. See `../version-availability/index.ts` for the full
73
+ * absent/empty/populated contract.
74
+ */
75
+ unavailableVersions?: ReadonlySet<string>;
63
76
  /** Version banner type ("unmaintained" | "unreleased") or undefined on latest. */
64
77
  versionBanner?: "unmaintained" | "unreleased";
65
78
  /** URL of the latest equivalent page for the version banner link. */
@@ -7,6 +7,11 @@ import {
7
7
  createSidebarPrepaint,
8
8
  createSidebarVisibilityPrepaint
9
9
  } from "../sidebar-prepaint/index.js";
10
+ import {
11
+ createTocPrepaint,
12
+ createTocVisibilityPrepaint
13
+ } from "../toc-prepaint/index.js";
14
+ import { serializeUnavailableVersions } from "../version-availability/index.js";
10
15
  import { createHeadWithDefaults } from "../head-with-defaults/index.js";
11
16
  import { resolveThemePackSsrSlug } from "../theme/theme-pack-provider.js";
12
17
  import { createDocBodyEnd } from "../doc-body-end/index.js";
@@ -36,6 +41,13 @@ function createDocPageShell(ctx) {
36
41
  const SidebarVisibilityPrepaint = createSidebarVisibilityPrepaint({
37
42
  sidebarToggle: sidebarToggleEnabled
38
43
  });
44
+ const tocToggleEnabled = Boolean(
45
+ ctx.settings.tocToggle
46
+ );
47
+ const TocPrepaint = createTocPrepaint({ tocToggle: tocToggleEnabled });
48
+ const TocVisibilityPrepaint = createTocVisibilityPrepaint({
49
+ tocToggle: tocToggleEnabled
50
+ });
39
51
  const DocBodyEnd = createDocBodyEnd(ctx);
40
52
  const dataThemePack = resolveThemePackSsrSlug(
41
53
  ctx.themePackRegistry,
@@ -60,6 +72,7 @@ function createDocPageShell(ctx) {
60
72
  currentPath,
61
73
  currentVersion,
62
74
  versionSwitcher,
75
+ unavailableVersions,
63
76
  versionBanner,
64
77
  versionBannerLatestUrl,
65
78
  versionBannerLabels,
@@ -72,6 +85,7 @@ function createDocPageShell(ctx) {
72
85
  } = props;
73
86
  const tocTitle = getTocTitle(locale);
74
87
  const shouldRenderToc = !hideToc && headings.length > 0;
88
+ const shouldRenderDefaultToc = shouldRenderToc && !customTocIsPresent;
75
89
  const tocOverride = shouldRenderToc ? customTocIsPresent ? /* @__PURE__ */ jsx(Toc, { headings, title: tocTitle }) : (
76
90
  // The zfb <Island> wrapper renders a bare <div> with no class, so
77
91
  // below xl (where <Toc> itself is `hidden xl:flex`) it would remain
@@ -80,7 +94,22 @@ function createDocPageShell(ctx) {
80
94
  // left at mobile widths. Hide the flex child itself below xl so it
81
95
  // contributes no gap; the bare-nav override branch above is already
82
96
  // self-hiding via <Toc>'s own `hidden xl:flex`. (#3082)
83
- /* @__PURE__ */ jsx("div", { class: "hidden xl:block", children: Island({
97
+ //
98
+ // Load-bearing for the TOC's sticky scroll-follow: this wrapper must
99
+ // be `xl:flex`, never `xl:block`. safelist-ok: `xl:block` names the rejected alternative in prose; only `xl:flex` below is emitted
100
+ // A sticky box can only travel within its parent's box, and the
101
+ // classless <Island> div sits between this wrapper and <Toc>'s
102
+ // sticky <nav>. As a block container that div is auto-height, so it
103
+ // collapses to exactly the nav's height and the nav has zero travel
104
+ // — it scrolls away with the page instead of pinning. Flex stretches
105
+ // the Island div to full content-band height (default
106
+ // `align-items: stretch`), restoring the travel range. (#3202)
107
+ //
108
+ // `zd-toc-col` is the stable hook class for the desktop TOC-toggle
109
+ // CSS (features.css "Desktop TOC toggle" block, #3254) — only the
110
+ // default-TOC wrapper carries it; the custom-Toc branch above stays
111
+ // bare and unaffected by the toggle feature.
112
+ /* @__PURE__ */ jsx("div", { class: "zd-toc-col hidden xl:flex", children: Island({
84
113
  when: "load",
85
114
  children: /* @__PURE__ */ jsx(Toc, { headings, title: tocTitle })
86
115
  }) })
@@ -96,7 +125,8 @@ function createDocPageShell(ctx) {
96
125
  description: settings.metaTags.description ? description : void 0,
97
126
  head: /* @__PURE__ */ jsxs(Fragment, { children: [
98
127
  /* @__PURE__ */ jsx(HeadWithDefaults, { title, description, canonical }),
99
- /* @__PURE__ */ jsx(SidebarVisibilityPrepaint, { hideSidebar })
128
+ /* @__PURE__ */ jsx(SidebarVisibilityPrepaint, { hideSidebar }),
129
+ /* @__PURE__ */ jsx(TocVisibilityPrepaint, { shouldRenderDefaultToc })
100
130
  ] }),
101
131
  lang: locale,
102
132
  dataThemePack,
@@ -133,10 +163,14 @@ function createDocPageShell(ctx) {
133
163
  ),
134
164
  tocOverride,
135
165
  mobileTocOverride,
136
- afterSidebar: /* @__PURE__ */ jsx(SidebarPrepaint, { hideSidebar }),
166
+ afterSidebar: /* @__PURE__ */ jsxs(Fragment, { children: [
167
+ /* @__PURE__ */ jsx(SidebarPrepaint, { hideSidebar }),
168
+ /* @__PURE__ */ jsx(TocPrepaint, { shouldRenderDefaultToc })
169
+ ] }),
137
170
  footerOverride: /* @__PURE__ */ jsx(FooterWithDefaults, { lang: locale }),
138
171
  bodyEndComponents: /* @__PURE__ */ jsx(DocBodyEnd, {}),
139
172
  enableClientRouter: settings.dynamicPageTransition,
173
+ articleAttrs: serializeUnavailableVersions(unavailableVersions),
140
174
  children: kind === "autoIndex" ? (
141
175
  /* Auto-index page: category without an index.mdx.
142
176
  Fragment (not <div>) so children become direct children of
@@ -89,6 +89,20 @@ export interface DocLayoutProps extends DocLayoutHtmlAttrs {
89
89
  * doc-history feature.
90
90
  */
91
91
  afterContent?: ComponentChildren;
92
+ /**
93
+ * Raw `data-*` attributes spread onto the `<article>` element. This shell
94
+ * stays version/i18n-agnostic on purpose (see the module header), so it
95
+ * has no idea what any given key means — it is a generic passthrough, not
96
+ * a versioning-aware prop. `<DocPageShell>` (one level up) is the actual
97
+ * owner of the one entry currently threaded through here: the per-page
98
+ * version-availability payload documented in
99
+ * `../version-availability/index.ts` (`UNAVAILABLE_VERSIONS_ATTR`). Placed
100
+ * on `<article>` rather than a new wrapper element because `<article>` is
101
+ * always present, sits inside `<main>` (swapped content — see the SPA
102
+ * persist note on the sidebar `<aside>` above), and needs no DOM node of
103
+ * its own that could shift the `.zd-content` flow-space rhythm.
104
+ */
105
+ articleAttrs?: Record<string, string>;
92
106
  /** Optional desktop TOC rendered alongside `<main>` on wide screens. */
93
107
  toc?: ComponentChildren;
94
108
  /** Hide the TOC (both desktop and mobile) regardless of slot value. */
@@ -21,6 +21,7 @@ function DocLayout(props) {
21
21
  mobileToc,
22
22
  main,
23
23
  afterContent,
24
+ articleAttrs,
24
25
  toc,
25
26
  hideToc = false,
26
27
  contentWide = false,
@@ -54,7 +55,8 @@ function DocLayout(props) {
54
55
  "data-sidebar-hidden",
55
56
  "data-theme",
56
57
  "data-theme-pack",
57
- "style"
58
+ "style",
59
+ "data-toc-hidden"
58
60
  ]
59
61
  }) : null,
60
62
  head
@@ -88,7 +90,7 @@ function DocLayout(props) {
88
90
  breadcrumb,
89
91
  afterBreadcrumb,
90
92
  !hideToc && mobileToc,
91
- /* @__PURE__ */ jsx("article", { class: "zd-content max-w-none", children: main }),
93
+ /* @__PURE__ */ jsx("article", { class: "zd-content max-w-none", ...articleAttrs, children: main }),
92
94
  afterContent
93
95
  ] }),
94
96
  showToc && toc
package/dist/features.css CHANGED
@@ -372,6 +372,66 @@ html[data-sidebar-hidden] .zd-doc-content-band[data-zd-wide] {
372
372
  * before paint, so the sidebar never wipes-and-restores and no geometry
373
373
  * transition fires mid-swap. Retires the #2198 workaround (zudolab/zudo-doc#2200). */
374
374
 
375
+ /* ========================================
376
+ * Desktop TOC toggle — 1:1 mirror of "Desktop sidebar toggle" above (epic
377
+ * #3252, #3254). Breakpoint is xl/1280px, NOT the sidebar's lg/1024px.
378
+ *
379
+ * `data-toc-hidden` persists across SPA navigation (doc-layout.tsx
380
+ * preserveHtmlAttrs), so EVERY hidden-state rule below is scoped to the
381
+ * default-TOC path (`.zd-toc-col` descendants / `:has(> .zd-toc-col)`) — an
382
+ * unscoped rule would collapse/squish a *custom* Toc reached after hiding the
383
+ * default TOC on another page.
384
+ * ======================================== */
385
+
386
+ @media (min-width: 1280px) {
387
+ .zd-toc-col {
388
+ width: var(--zdc-toc-width, 280px); /* explicit — auto→0 does not interpolate */
389
+ overflow: clip; /* NOT hidden: hidden creates a scroll container and breaks the nav's sticky */
390
+ transition: width var(--zd-transition-slow) ease-in-out;
391
+ }
392
+
393
+ html[data-toc-hidden] .zd-toc-col {
394
+ width: 0;
395
+ }
396
+
397
+ /* :has guard keeps the gap intact on custom-Toc pages (wrapper is a direct
398
+ * band child). Without :has support the gap simply stays — cosmetic
399
+ * degradation only. Alternative if :has ever becomes a concern: a band
400
+ * marker attribute. */
401
+ html[data-toc-hidden] .zd-doc-content-band:has(> .zd-toc-col) {
402
+ gap: 0;
403
+ }
404
+
405
+ /* COMBINED list — a bare `transition: gap` here would override and drop the
406
+ * max-width transition declared in the sidebar's 1024px block (last wins at
407
+ * xl, same property on the same selector). No band max-width rule is added
408
+ * here (expand-main policy): `main` is already `flex-1 min-w-0` and absorbs
409
+ * the freed width by itself; both-hidden composes with the existing
410
+ * `html[data-sidebar-hidden] .zd-doc-content-band { max-width: 80rem }` cap. */
411
+ .zd-doc-content-band {
412
+ transition:
413
+ max-width var(--zd-transition-slow) ease-in-out,
414
+ gap var(--zd-transition-slow) ease-in-out;
415
+ }
416
+
417
+ .zd-toc-col nav[data-zd-toc] {
418
+ transition:
419
+ opacity var(--zd-transition-slow) ease-in-out,
420
+ transform var(--zd-transition-slow) ease-in-out,
421
+ visibility var(--zd-transition-slow);
422
+ }
423
+
424
+ html[data-toc-hidden] .zd-toc-col nav[data-zd-toc] {
425
+ opacity: 0;
426
+ transform: translateX(1.5rem);
427
+ visibility: hidden; /* a11y: clipped links must not be focusable */
428
+ }
429
+
430
+ .zd-desktop-toc-toggle {
431
+ right: 0; /* constant — pins to the viewport edge; no tracking var needed */
432
+ }
433
+ }
434
+
375
435
  /* ========================================
376
436
  * Page transition animations (view-transition chrome)
377
437
  * ======================================== */
@@ -403,19 +463,21 @@ html[data-sidebar-hidden] .zd-doc-content-band[data-zd-wide] {
403
463
  * transitions via document.startViewTransition on each navigation.
404
464
  *
405
465
  * Chrome extraction via view-transition-name (Strategy B+, zudolab/zudo-doc#1558):
406
- * <header>, <aside id="desktop-sidebar">, <footer>, and the desktop-sidebar-toggle
407
- * button each carry a data-zfb-transition-persist attribute whose value is keyed
408
- * by locale and nav-section. The four attribute selectors below assign a stable
466
+ * <header>, <aside id="desktop-sidebar">, <footer>, the desktop-sidebar-toggle
467
+ * button, and (since #3254) the desktop-toc-toggle button each carry a
468
+ * data-zfb-transition-persist attribute whose value is keyed by locale and
469
+ * nav-section. The five attribute selectors below assign a stable
409
470
  * view-transition-name based on the attribute value prefix, extracting those
410
471
  * elements from the root snapshot into their own named layers:
411
472
  * - header-{lang} → zfb-header
412
473
  * - sidebar-{locale}-… → zfb-sidebar
413
474
  * - footer-{lang} → zfb-footer
414
475
  * - desktop-sidebar-toggle → zfb-sidebar-toggle
476
+ * - desktop-toc-toggle → zfb-toc-toggle
415
477
  *
416
478
  * Once extracted, the root cross-fade animates only non-chrome content (main,
417
- * article, TOC, etc.). The twelve ::view-transition-{old,new,group}(<name>)
418
- * rules disable animation for all four chrome layers. The group pseudo must be
479
+ * article, TOC, etc.). The ::view-transition-{old,new,group}(<name>) rules
480
+ * disable animation for all five chrome layers. The group pseudo must be
419
481
  * neutralised too — even when old/new are static the group container can still
420
482
  * produce a geometry-morph animation when snapshot size/position differs.
421
483
  *
@@ -434,8 +496,11 @@ html[data-sidebar-hidden] .zd-doc-content-band[data-zd-wide] {
434
496
  [data-zfb-transition-persist^="sidebar-"] { view-transition-name: zfb-sidebar; }
435
497
  [data-zfb-transition-persist^="footer-"] { view-transition-name: zfb-footer; }
436
498
  [data-zfb-transition-persist="desktop-sidebar-toggle"] { view-transition-name: zfb-sidebar-toggle; }
499
+ [data-zfb-transition-persist="desktop-toc-toggle"] { view-transition-name: zfb-toc-toggle; }
437
500
 
438
- /* Disable animation for all four chrome layers (old, new, and group) */
501
+ /* Disable animation for all four chrome layers (old, new, and group), plus
502
+ * the desktop-toc-toggle button (#3254 — mirrors desktop-sidebar-toggle so
503
+ * it doesn't animate on SPA swaps). */
439
504
  ::view-transition-old(zfb-header),
440
505
  ::view-transition-new(zfb-header),
441
506
  ::view-transition-group(zfb-header),
@@ -447,21 +512,26 @@ html[data-sidebar-hidden] .zd-doc-content-band[data-zd-wide] {
447
512
  ::view-transition-group(zfb-footer),
448
513
  ::view-transition-old(zfb-sidebar-toggle),
449
514
  ::view-transition-new(zfb-sidebar-toggle),
450
- ::view-transition-group(zfb-sidebar-toggle) { animation: none; }
515
+ ::view-transition-group(zfb-sidebar-toggle),
516
+ ::view-transition-old(zfb-toc-toggle),
517
+ ::view-transition-new(zfb-toc-toggle),
518
+ ::view-transition-group(zfb-toc-toggle) { animation: none; }
451
519
 
452
520
  /* Entry/exit: chrome element present on one side only (#2072).
453
521
  * Exit — lone old snapshot fades out with the content cross-fade. */
454
522
  ::view-transition-old(zfb-header):only-child,
455
523
  ::view-transition-old(zfb-sidebar):only-child,
456
524
  ::view-transition-old(zfb-footer):only-child,
457
- ::view-transition-old(zfb-sidebar-toggle):only-child {
525
+ ::view-transition-old(zfb-sidebar-toggle):only-child,
526
+ ::view-transition-old(zfb-toc-toggle):only-child {
458
527
  animation: var(--default-transition-duration) ease-in both contentFadeOut;
459
528
  }
460
529
  /* Entry — lone new snapshot fades in with the content cross-fade. */
461
530
  ::view-transition-new(zfb-header):only-child,
462
531
  ::view-transition-new(zfb-sidebar):only-child,
463
532
  ::view-transition-new(zfb-footer):only-child,
464
- ::view-transition-new(zfb-sidebar-toggle):only-child {
533
+ ::view-transition-new(zfb-sidebar-toggle):only-child,
534
+ ::view-transition-new(zfb-toc-toggle):only-child {
465
535
  animation: var(--zd-transition-slower) ease-out both contentFadeIn;
466
536
  }
467
537
 
@@ -492,10 +562,12 @@ html[data-sidebar-hidden] .zd-doc-content-band[data-zd-wide] {
492
562
  ::view-transition-old(zfb-sidebar):only-child,
493
563
  ::view-transition-old(zfb-footer):only-child,
494
564
  ::view-transition-old(zfb-sidebar-toggle):only-child,
565
+ ::view-transition-old(zfb-toc-toggle):only-child,
495
566
  ::view-transition-new(zfb-header):only-child,
496
567
  ::view-transition-new(zfb-sidebar):only-child,
497
568
  ::view-transition-new(zfb-footer):only-child,
498
- ::view-transition-new(zfb-sidebar-toggle):only-child {
569
+ ::view-transition-new(zfb-sidebar-toggle):only-child,
570
+ ::view-transition-new(zfb-toc-toggle):only-child {
499
571
  animation: none;
500
572
  }
501
573
  }
@@ -65,6 +65,7 @@ function createHeadWithDefaults(ctx) {
65
65
  }
66
66
  ),
67
67
  settings.sidebarResizer && /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: SIDEBAR_RESIZER_RESTORE_SCRIPT } }),
68
+ /* @__PURE__ */ jsx("link", { rel: "icon", type: "image/svg+xml", href: withBase("/favicon.svg") }),
68
69
  /* @__PURE__ */ jsx("link", { rel: "icon", href: withBase("/favicon.ico"), sizes: "any" }),
69
70
  /* @__PURE__ */ jsx("link", { rel: "icon", type: "image/png", sizes: "32x32", href: withBase("/favicon-32x32.png") }),
70
71
  /* @__PURE__ */ jsx("link", { rel: "icon", type: "image/png", sizes: "16x16", href: withBase("/favicon-16x16.png") }),
@@ -10,7 +10,7 @@ import type { HeaderNavItem, HeaderRightComponentRegistry, HeaderRightItem, Loca
10
10
  export interface HeaderUrlHelpers {
11
11
  withBase(path: string): string;
12
12
  stripBase(path: string): string;
13
- navHref(path: string, lang?: Locale, currentVersion?: string): string;
13
+ navHref(path: string, lang?: Locale, currentVersion?: string, versioned?: boolean): string;
14
14
  }
15
15
  /**
16
16
  * Boundary helpers for locale-aware behaviour the host injects into
@@ -166,7 +166,7 @@ function SidebarSlotFallback({
166
166
  }
167
167
  function renderNavItem(item, activeNavPath, activeCategory, lang, currentVersion, urlHelpers, i18n) {
168
168
  const isActive = isNavItemActiveByCategory(item, activeCategory) || isNavItemActive(item, activeNavPath);
169
- const href = urlHelpers.navHref(item.path, lang, currentVersion);
169
+ const href = urlHelpers.navHref(item.path, lang, currentVersion, item.versioned);
170
170
  const label = item.labelKey ? i18n.t(item.labelKey, lang) : item.label;
171
171
  if (item.children && item.children.length > 0) {
172
172
  return /* @__PURE__ */ jsxs(
@@ -215,7 +215,7 @@ function renderNavItem(item, activeNavPath, activeCategory, lang, currentVersion
215
215
  }
216
216
  ),
217
217
  /* @__PURE__ */ jsx("div", { class: "absolute left-0 top-full z-dropdown hidden group-hover:block group-focus-within:block pt-vsp-3xs", children: /* @__PURE__ */ jsx("div", { class: "min-w-[10rem] border border-muted rounded bg-surface shadow-lg py-vsp-3xs", children: item.children.map((child) => {
218
- const childHref = urlHelpers.navHref(child.path, lang, currentVersion);
218
+ const childHref = urlHelpers.navHref(child.path, lang, currentVersion, child.versioned);
219
219
  const childLabel = child.labelKey ? i18n.t(child.labelKey, lang) : child.label;
220
220
  const childActive = isNavItemActiveByCategory(child, activeCategory) || activeNavPath === child.path;
221
221
  return /* @__PURE__ */ jsx(
@@ -11,6 +11,13 @@ export interface HeaderNavChildItem {
11
11
  labelKey?: string;
12
12
  path: string;
13
13
  categoryMatch?: string;
14
+ /**
15
+ * Whether links built from this item's `path` carry the active `/v/{version}`
16
+ * prefix. `false` targets a route that has no versioned counterpart, so its
17
+ * href must stay unversioned even when rendered under an active version.
18
+ * Default `true`.
19
+ */
20
+ versioned?: boolean;
14
21
  }
15
22
  export interface HeaderNavItem extends HeaderNavChildItem {
16
23
  children?: HeaderNavChildItem[];
@@ -32,10 +32,12 @@ export interface HeaderWithDefaultsSettings {
32
32
  labelKey?: string;
33
33
  path: string;
34
34
  categoryMatch?: string;
35
+ versioned?: boolean;
35
36
  children?: Array<{
36
37
  label: string;
37
38
  labelKey?: string;
38
39
  path: string;
40
+ versioned?: boolean;
39
41
  }>;
40
42
  }>;
41
43
  headerRightItems?: unknown[];
@@ -9,7 +9,11 @@ import { ThemeToggle } from "../theme-toggle/index.js";
9
9
  import { SidebarToggle } from "../sidebar-toggle-island/index.js";
10
10
  import { buildGitHubRepoUrl as buildGitHubRepoUrlBase } from "../github-helpers/index.js";
11
11
  import { assertChromeContext } from "../chrome/assert-chrome-context.js";
12
- import { deriveNavDataPrep, deriveSearchWidgetSlot } from "../chrome/derive.js";
12
+ import {
13
+ deriveGetUnavailableVersions,
14
+ deriveNavDataPrep,
15
+ deriveSearchWidgetSlot
16
+ } from "../chrome/derive.js";
13
17
  function createHeaderWithDefaults(ctx) {
14
18
  assertChromeContext(ctx, "createHeaderWithDefaults");
15
19
  const settings = ctx.settings;
@@ -24,6 +28,7 @@ function createHeaderWithDefaults(ctx) {
24
28
  const { buildRootMenuItems, buildLocaleLinksForNav, buildSidebarNodes, getThemeDefaultMode } = deriveNavDataPrep(ctx);
25
29
  const buildGitHubRepoUrl = () => buildGitHubRepoUrlBase(ctx.settings.githubUrl);
26
30
  const SearchWidget = deriveSearchWidgetSlot(ctx);
31
+ const getUnavailableVersions = deriveGetUnavailableVersions(ctx);
27
32
  function HeaderWithDefaults(props) {
28
33
  const {
29
34
  lang: langProp = defaultLocale,
@@ -85,6 +90,7 @@ function createHeaderWithDefaults(ctx) {
85
90
  unavailable: t("version.switcher.unavailable", lang),
86
91
  allVersions: t("version.switcher.allVersions", lang)
87
92
  };
93
+ const unavailableVersions = getUnavailableVersions(currentSlug, lang);
88
94
  versionSwitcher = /* @__PURE__ */ jsx(
89
95
  VersionSwitcher,
90
96
  {
@@ -96,6 +102,7 @@ function createHeaderWithDefaults(ctx) {
96
102
  latestUrl,
97
103
  versionsPageUrl,
98
104
  versionUrls,
105
+ unavailableVersions,
99
106
  labels,
100
107
  idSuffix: "header",
101
108
  rewireConfig: {
@@ -158,7 +165,7 @@ function createHeaderWithDefaults(ctx) {
158
165
  urlHelpers: {
159
166
  withBase,
160
167
  stripBase,
161
- navHref: (path, l, v) => navHref(path, l, v)
168
+ navHref: (path, l, v, versioned) => navHref(path, l, v, versioned)
162
169
  },
163
170
  i18n: {
164
171
  defaultLocale,
@@ -169,6 +169,42 @@ export declare const VERSION_SWITCHER_INIT_SCRIPT: string;
169
169
  * {@link VersionSwitcherRewireConfig} is passed), so the inline breadcrumb
170
170
  * switcher — re-rendered fresh on every swap — is left alone.
171
171
  *
172
+ * Since #3244, `rewire()` also recomputes each version entry's
173
+ * enabled/disabled state from the per-page availability payload #3243 emits
174
+ * onto the swapped `<article>` (`UNAVAILABLE_VERSIONS_ATTR`, imported here
175
+ * as `ATTR` — see `version-availability/index.ts` for the three-state
176
+ * contract). The persisted header would otherwise keep showing whichever
177
+ * entries were disabled/enabled on the PREVIOUS page after a same-locale SPA
178
+ * navigation — the exact bug epic #3242 exists to fix.
179
+ *
180
+ * The three-state contract is preserved faithfully here (fixed after a P2
181
+ * codex review finding on the original #3244 landing, which collapsed ABSENT
182
+ * into EMPTY and re-enabled every entry — turning SSR-correct disabled links
183
+ * into live 404s whenever a page renders through `createDocPageShell` without
184
+ * an availability payload):
185
+ * - attribute ABSENT (`hasAvailabilityData` false) → no availability data
186
+ * for the destination page. `setDisabled` is never called for any entry;
187
+ * the SSR-rendered disabled/enabled state is left exactly as-is. Only the
188
+ * genuinely path-derived bits (href, active state on entries that are
189
+ * NOT currently disabled) are recomputed.
190
+ * - attribute present, value `""` → empty unavailable set, i.e. "everything
191
+ * available" — `setDisabled(a, false, …)` runs for every entry, matching
192
+ * the SSR component's own `!unavailableVersions || !unavailableVersions.has(slug)`
193
+ * fallback (`version-switcher.tsx`'s `isAvailable` check).
194
+ * - attribute present, `"a,b"` → those slugs disabled, the rest enabled.
195
+ *
196
+ * `setDisabled` and the `setActive` guard together transition ALL FIVE
197
+ * SSR-divergent properties in both directions (`aria-disabled`, `tabindex`,
198
+ * `title`, the disjoint class sets, `aria-current`) — see the case table in
199
+ * `__tests__/version-switcher.test.tsx` that pins this against the real SSR
200
+ * branches. `setActive` runs strictly AFTER `setDisabled` re-enables an
201
+ * entry, so a newly-available active entry gets `aria-current="page"`
202
+ * restored instead of silently staying without it. In the ABSENT branch,
203
+ * where `setDisabled` never runs, `setActive` instead reads the anchor's
204
+ * OWN current `aria-disabled` attribute directly (the only source of truth
205
+ * left, since availability isn't being recomputed) and skips already-disabled
206
+ * entries the same way.
207
+ *
172
208
  * `window[FLAG]` makes it idempotent: the tag may re-execute on a hard reload or
173
209
  * a cross-locale header repaint, but the listener registers exactly once per
174
210
  * page lifetime.
@@ -1,5 +1,6 @@
1
1
  import { jsx, jsxs } from "preact/jsx-runtime";
2
2
  import { AFTER_NAVIGATE_EVENT } from "../transitions/page-events.js";
3
+ import { UNAVAILABLE_VERSIONS_ATTR } from "../version-availability/index.js";
3
4
  function computeVersionSwitcherState(pathname, config, versionSlugs) {
4
5
  const normalizedBase = config.base;
5
6
  const defaultLocale = config.defaultLocale;
@@ -105,7 +106,8 @@ function VersionSwitcher(props) {
105
106
  "data-base": rewireConfig.base,
106
107
  "data-default-locale": rewireConfig.defaultLocale,
107
108
  "data-trailing-slash": String(rewireConfig.trailingSlash),
108
- "data-current-locale": rewireConfig.currentLocale
109
+ "data-current-locale": rewireConfig.currentLocale,
110
+ "data-unavailable-label": labels.unavailable
109
111
  } : {};
110
112
  return /* @__PURE__ */ jsxs("div", { class: "version-switcher relative", "data-version-switcher": true, ...rewireAttrs, children: [
111
113
  /* @__PURE__ */ jsxs(
@@ -232,6 +234,7 @@ const VERSION_SWITCHER_REWIRE_SCRIPT = `(function(){
232
234
  var FLAG="__zdVersionSwitcherRewire";
233
235
  if(window[FLAG])return;
234
236
  window[FLAG]=true;
237
+ var ATTR=${JSON.stringify(UNAVAILABLE_VERSIONS_ATTR)};
235
238
  var computeVersionSwitcherState=${computeVersionSwitcherState.toString()};
236
239
  function setActive(a,active){
237
240
  a.classList.toggle("font-bold",active);
@@ -239,11 +242,34 @@ a.classList.toggle("text-accent",active);
239
242
  a.classList.toggle("text-fg",!active);
240
243
  if(active){a.setAttribute("aria-current","page");}else{a.removeAttribute("aria-current");}
241
244
  }
245
+ function setDisabled(a,disabled,unavailableLabel){
246
+ a.classList.toggle("hover:bg-accent/10",!disabled);
247
+ a.classList.toggle("hover:underline",!disabled);
248
+ a.classList.toggle("focus-visible:underline",!disabled);
249
+ a.classList.toggle("text-muted/50",disabled);
250
+ a.classList.toggle("cursor-not-allowed",disabled);
251
+ a.classList.toggle("pointer-events-none",disabled);
252
+ if(disabled){
253
+ a.setAttribute("aria-disabled","true");
254
+ a.setAttribute("tabindex","-1");
255
+ a.setAttribute("title",unavailableLabel);
256
+ a.classList.remove("font-bold","text-accent","text-fg");
257
+ a.removeAttribute("aria-current");
258
+ }else{
259
+ a.removeAttribute("aria-disabled");
260
+ a.removeAttribute("tabindex");
261
+ a.removeAttribute("title");
262
+ }
263
+ }
242
264
  function rewire(){
265
+ var articleEl=document.querySelector("["+ATTR+"]");
266
+ var hasAvailabilityData=articleEl!==null;
267
+ var unavailableSlugs=hasAvailabilityData?(articleEl.getAttribute(ATTR)||"").split(",").filter(Boolean):[];
243
268
  var containers=document.querySelectorAll("[data-version-rewire]");
244
269
  for(var i=0;i<containers.length;i++){
245
270
  var c=containers[i];
246
271
  var config={base:c.getAttribute("data-base")||"",defaultLocale:c.getAttribute("data-default-locale")||"",trailingSlash:c.getAttribute("data-trailing-slash")==="true",currentLocale:c.getAttribute("data-current-locale")||""};
272
+ var unavailableLabel=c.getAttribute("data-unavailable-label")||"";
247
273
  var versionAnchors=c.querySelectorAll("[data-version-slug]");
248
274
  var slugs=[];
249
275
  for(var j=0;j<versionAnchors.length;j++){
@@ -262,7 +288,14 @@ var slug=a.getAttribute("data-version-slug");
262
288
  if(!slug)continue;
263
289
  var href=state.versionHrefs[slug];
264
290
  if(href!=null)a.setAttribute("href",href);
265
- if(!a.hasAttribute("aria-disabled"))setActive(a,state.activeVersion===slug);
291
+ if(hasAvailabilityData){
292
+ var disabled=unavailableSlugs.indexOf(slug)!==-1;
293
+ setDisabled(a,disabled,unavailableLabel);
294
+ if(!disabled)setActive(a,state.activeVersion===slug);
295
+ }else{
296
+ var alreadyDisabled=a.getAttribute("aria-disabled")==="true";
297
+ if(!alreadyDisabled)setActive(a,state.activeVersion===slug);
298
+ }
266
299
  }
267
300
  var label=c.querySelector("[data-version-trigger-label]");
268
301
  if(label){
@@ -278,7 +311,14 @@ label.textContent=activeLabel!=null?activeLabel:state.activeVersion;
278
311
  }
279
312
  }
280
313
  }
281
- rewire();
314
+ // The initial call is deferred to DOMContentLoaded when the script (inline in
315
+ // <header>, which parses before <article>) would otherwise run before the
316
+ // article element exists. Running early would read a missing ATTR as "no
317
+ // unavailable slugs" and clobber the SSR-correct disabled state with
318
+ // everything-enabled \u2014 a first-paint variant of the bug this rewire exists
319
+ // to fix (see the doc comment above; SSR already rendered the right state for
320
+ // this page, so a brief deferral loses nothing).
321
+ if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",rewire);}else{rewire();}
282
322
  document.addEventListener(${JSON.stringify(AFTER_NAVIGATE_EVENT)},rewire);
283
323
  })();`;
284
324
  export {
@@ -23,6 +23,24 @@ export interface InlineVersionSwitcherDeps {
23
23
  versionedDocsUrl: (slug: string, versionSlug: string, lang?: string) => string;
24
24
  /** Prefix a path with the configured base directory. */
25
25
  withBase: (path: string) => string;
26
+ /**
27
+ * Slugs of configured versions where the given (slug, locale) is NOT
28
+ * available — passed straight through as `<VersionSwitcher
29
+ * unavailableVersions>` so absent pages render disabled instead of 404
30
+ * links (#3215). Returns `undefined` when there is no availability data to
31
+ * compute (the shared helper already handles the no-slug/no-versions
32
+ * gates — see `version-availability`).
33
+ *
34
+ * Optional — `./inline-version-switcher` is a documented frozen-1.0 public
35
+ * subpath (`packages/zudo-doc/CLAUDE.md`/`API.md`), so a pre-#3215 caller
36
+ * that hand-constructs `InlineVersionSwitcherDeps` without this field must
37
+ * keep compiling. Omitting it reports no version as unavailable — byte-for-
38
+ * byte the pre-#3215 rendering — so unlike the nav wrappers' optional
39
+ * `versionedDocsUrl` (whose absence yields hrefs that escape the version
40
+ * silo) there is nothing degraded to warn about, and this builder runs on
41
+ * every versioned doc page so a warning would be pure per-page noise.
42
+ */
43
+ getUnavailableVersions?: (slug: string | undefined, locale: string) => ReadonlySet<string> | undefined;
26
44
  }
27
45
  /**
28
46
  * Build the `buildInlineVersionSwitcher` function bound to the host's settings
@@ -3,7 +3,7 @@ import {
3
3
  VersionSwitcher
4
4
  } from "../i18n-version/index.js";
5
5
  function createInlineVersionSwitcher(deps) {
6
- const { settings, defaultLocale, t, docsUrl, versionedDocsUrl, withBase } = deps;
6
+ const { settings, defaultLocale, t, docsUrl, versionedDocsUrl, withBase, getUnavailableVersions } = deps;
7
7
  function buildInlineVersionSwitcher(slug, locale, currentVersion) {
8
8
  if (!settings.versions || settings.versions.length === 0) return void 0;
9
9
  const isNonDefaultLocale = locale !== defaultLocale;
@@ -21,6 +21,7 @@ function createInlineVersionSwitcher(deps) {
21
21
  unavailable: t("version.switcher.unavailable", locale),
22
22
  allVersions: t("version.switcher.allVersions", locale)
23
23
  };
24
+ const unavailableVersions = getUnavailableVersions?.(slug, locale);
24
25
  return /* @__PURE__ */ jsx(
25
26
  VersionSwitcher,
26
27
  {
@@ -32,6 +33,7 @@ function createInlineVersionSwitcher(deps) {
32
33
  latestUrl,
33
34
  versionsPageUrl,
34
35
  versionUrls,
36
+ unavailableVersions,
35
37
  labels,
36
38
  idSuffix: "inline"
37
39
  }