@takazudo/zudo-doc 0.2.22 → 1.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 (103) hide show
  1. package/bin/zudo-doc.mjs +83 -0
  2. package/dist/eject/index.d.ts +33 -0
  3. package/dist/eject/index.js +229 -0
  4. package/dist/plugins/routes.d.ts +2 -0
  5. package/dist/plugins/routes.js +100 -0
  6. package/dist/preset.d.ts +47 -1
  7. package/dist/preset.js +29 -13
  8. package/dist/routes/404.d.ts +7 -0
  9. package/dist/routes/404.js +47 -0
  10. package/dist/routes/_chrome.d.ts +32 -0
  11. package/dist/routes/_chrome.js +365 -0
  12. package/dist/routes/_context.d.ts +56 -0
  13. package/dist/routes/_context.js +222 -0
  14. package/dist/routes/_docs-helpers.d.ts +43 -0
  15. package/dist/routes/_docs-helpers.js +173 -0
  16. package/dist/routes/_virtual.d.js +0 -0
  17. package/dist/routes/api-ai-chat.d.ts +5 -0
  18. package/dist/routes/api-ai-chat.js +24 -0
  19. package/dist/routes/docs-slug.d.ts +21 -0
  20. package/dist/routes/docs-slug.js +26 -0
  21. package/dist/routes/docs-tags-index.d.ts +7 -0
  22. package/dist/routes/docs-tags-index.js +11 -0
  23. package/dist/routes/docs-tags-tag.d.ts +23 -0
  24. package/dist/routes/docs-tags-tag.js +19 -0
  25. package/dist/routes/docs-versions.d.ts +7 -0
  26. package/dist/routes/docs-versions.js +11 -0
  27. package/dist/routes/index.d.ts +7 -0
  28. package/dist/routes/index.js +117 -0
  29. package/dist/routes/locale-docs-slug.d.ts +27 -0
  30. package/dist/routes/locale-docs-slug.js +40 -0
  31. package/dist/routes/locale-docs-tags-index.d.ts +18 -0
  32. package/dist/routes/locale-docs-tags-index.js +15 -0
  33. package/dist/routes/locale-docs-tags-tag.d.ts +25 -0
  34. package/dist/routes/locale-docs-tags-tag.js +22 -0
  35. package/dist/routes/locale-docs-versions.d.ts +18 -0
  36. package/dist/routes/locale-docs-versions.js +15 -0
  37. package/dist/routes/locale-index.d.ts +24 -0
  38. package/dist/routes/locale-index.js +132 -0
  39. package/dist/routes/robots.txt.d.ts +3 -0
  40. package/dist/routes/robots.txt.js +8 -0
  41. package/dist/routes/sitemap.xml.d.ts +5 -0
  42. package/dist/routes/sitemap.xml.js +31 -0
  43. package/dist/routes/v-docs-slug.d.ts +27 -0
  44. package/dist/routes/v-docs-slug.js +34 -0
  45. package/dist/routes/v-locale-docs-slug.d.ts +31 -0
  46. package/dist/routes/v-locale-docs-slug.js +50 -0
  47. package/dist/safelist.css +1 -1
  48. package/dist/settings.d.ts +15 -0
  49. package/eject/breadcrumb/breadcrumb.tsx +194 -0
  50. package/eject/breadcrumb/find-path.ts +26 -0
  51. package/eject/breadcrumb/index.ts +4 -0
  52. package/eject/breadcrumb/types.ts +32 -0
  53. package/eject/code-group/index.tsx +78 -0
  54. package/eject/content-admonition/index.tsx +50 -0
  55. package/eject/details/details.tsx +34 -0
  56. package/eject/details/index.ts +4 -0
  57. package/eject/doc-pager/index.tsx +91 -0
  58. package/eject/footer/footer.tsx +167 -0
  59. package/eject/footer/index.ts +19 -0
  60. package/eject/footer/types.ts +60 -0
  61. package/eject/header/header.tsx +725 -0
  62. package/eject/header/index.ts +36 -0
  63. package/eject/header/nav-active.ts +122 -0
  64. package/eject/header/nav-overflow-script.ts +302 -0
  65. package/eject/header/right-items.ts +78 -0
  66. package/eject/header/types.ts +69 -0
  67. package/eject/page-loading/index.ts +10 -0
  68. package/eject/page-loading/page-loading-overlay.tsx +103 -0
  69. package/eject/sidebar/index.ts +27 -0
  70. package/eject/sidebar/sidebar.tsx +137 -0
  71. package/eject/sidebar/types.ts +73 -0
  72. package/eject/tab-item/index.ts +7 -0
  73. package/eject/tab-item/tab-item.tsx +70 -0
  74. package/eject/theme-toggle/color-scheme-sync.ts +73 -0
  75. package/eject/theme-toggle/index.tsx +120 -0
  76. package/eject/toc/cx.ts +38 -0
  77. package/eject/toc/index.ts +11 -0
  78. package/eject/toc/mobile-toc.tsx +133 -0
  79. package/eject/toc/toc-title.ts +33 -0
  80. package/eject/toc/toc.tsx +119 -0
  81. package/eject/toc/types.ts +14 -0
  82. package/eject/toc/use-active-heading.ts +162 -0
  83. package/package.json +87 -7
  84. package/routes-src/404.tsx +49 -0
  85. package/routes-src/_chrome.tsx +470 -0
  86. package/routes-src/_context.ts +314 -0
  87. package/routes-src/_docs-helpers.ts +284 -0
  88. package/routes-src/_virtual.d.ts +15 -0
  89. package/routes-src/api-ai-chat.tsx +46 -0
  90. package/routes-src/docs-slug.tsx +43 -0
  91. package/routes-src/docs-tags-index.tsx +14 -0
  92. package/routes-src/docs-tags-tag.tsx +29 -0
  93. package/routes-src/docs-versions.tsx +15 -0
  94. package/routes-src/index.tsx +134 -0
  95. package/routes-src/locale-docs-slug.tsx +69 -0
  96. package/routes-src/locale-docs-tags-index.tsx +23 -0
  97. package/routes-src/locale-docs-tags-tag.tsx +39 -0
  98. package/routes-src/locale-docs-versions.tsx +23 -0
  99. package/routes-src/locale-index.tsx +150 -0
  100. package/routes-src/robots.txt.tsx +13 -0
  101. package/routes-src/sitemap.xml.tsx +48 -0
  102. package/routes-src/v-docs-slug.tsx +64 -0
  103. package/routes-src/v-locale-docs-slug.tsx +85 -0
@@ -0,0 +1,725 @@
1
+ /** @jsxRuntime automatic */
2
+ /** @jsxImportSource preact */
3
+
4
+ // Layout-level JSX port of `src/components/header` for the
5
+ // zudo-doc framework primitives layer (super-epic #473, sub-issue
6
+ // #476). The component is intentionally server-render-friendly: it
7
+ // emits the same markup the legacy Astro template did, leaving the two
8
+ // interactive child islands (the mobile sidebar toggle and the dropdown
9
+ // "..." overflow controller) as either consumer-supplied slots or an
10
+ // inline-script `<script dangerouslySetInnerHTML>`.
11
+ //
12
+ // Why this shape:
13
+ // * The Astro template embeds three Astro-only sub-components
14
+ // (`<LanguageSwitcher />`, `<VersionSwitcher />`, `<Search />`).
15
+ // None of those have a JSX equivalent yet (Task #3 ports them), so
16
+ // they are exposed as `languageSwitcher` / `versionSwitcher` /
17
+ // `search` slot props. The host project keeps using
18
+ // `header` until the sibling ports land — this file exists
19
+ // so consumers of the v2 package can opt into the JSX path early.
20
+ // * The two Preact islands (`SidebarToggle`, `ThemeToggle`) are also
21
+ // accepted as slots so consumers control hydration boundaries
22
+ // (e.g. wrap them in zfb's `<Island when="media">` / `<Island
23
+ // when="load">`). The matching `<slot name="sidebar" />` in the
24
+ // legacy template becomes `sidebarSlot` so the mobile-sheet tree
25
+ // can still flow in from the layout.
26
+ // * Everything host-derived (siteName, headerNav, headerRightItems,
27
+ // githubRepoUrl, …) is supplied via props. Sub-issue #1729 inverted
28
+ // the older direct `@/config` / `@/utils` imports — the host
29
+ // wrapper (`pages/lib/_header-with-defaults.tsx`) now computes the
30
+ // same values from its own modules and passes them through.
31
+ // Pure helpers used by the active-link logic live in
32
+ // `./nav-active.ts` so they stay unit-testable without booting the
33
+ // host config.
34
+ // * The inline overflow script is a pure-JS string emitted via
35
+ // `dangerouslySetInnerHTML` (see `./nav-overflow-script.ts`). The
36
+ // behaviour is identical to the original `<script>` block — only
37
+ // the TypeScript syntax was stripped because a raw `<script>` tag
38
+ // ships its body to the browser as-is.
39
+
40
+ import type { ComponentChildren, JSX, VNode } from "preact";
41
+ import {
42
+ computeActiveNavPath,
43
+ isNavItemActive,
44
+ isNavItemActiveByCategory,
45
+ pathForMatch,
46
+ } from "./nav-active.js";
47
+ import { NAV_OVERFLOW_SCRIPT } from "./nav-overflow-script.js";
48
+ import type {
49
+ HeaderNavItem,
50
+ HeaderRightItem,
51
+ Locale,
52
+ } from "./types.js";
53
+ import { GitHub as GitHubIcon } from "../icons/index.js";
54
+
55
+ /**
56
+ * Boundary helpers the host injects into `<Header>`. These are the URL
57
+ * builders the legacy header.tsx used to import from `@/utils/base` —
58
+ * pulled into a single prop bag so the v2 package is host-agnostic.
59
+ */
60
+ export interface HeaderUrlHelpers {
61
+ withBase(path: string): string;
62
+ stripBase(path: string): string;
63
+ navHref(
64
+ path: string,
65
+ lang?: Locale,
66
+ currentVersion?: string,
67
+ ): string;
68
+ }
69
+
70
+ /**
71
+ * Boundary helpers for locale-aware behaviour the host injects into
72
+ * `<Header>`. Matches the surface the legacy header.tsx used from
73
+ * `@/config/i18n`.
74
+ */
75
+ export interface HeaderI18n {
76
+ defaultLocale: string;
77
+ locales: readonly string[];
78
+ t(key: string, lang?: string): string;
79
+ }
80
+
81
+ /**
82
+ * Props for the JSX `<Header />` port. Mirrors the Astro template's
83
+ * `Props` interface plus a small set of slot props that replace child
84
+ * Astro components and named `<slot>` outlets.
85
+ */
86
+ export interface HeaderProps {
87
+ /** Active locale; `undefined` matches the legacy "no-lang" code path. */
88
+ lang?: Locale;
89
+ /** Current page URL path (as the layout passes from `Astro.url.pathname`). */
90
+ currentPath?: string;
91
+ /** Optional active version slug. Forwarded into `navHref` for nav links. */
92
+ currentVersion?: string;
93
+
94
+ /**
95
+ * The page's resolved "big category" (the host's nav section — see
96
+ * `getNavSectionForSlug`). When supplied, the header highlights the
97
+ * nav item whose `categoryMatch` (or a child's) equals this value,
98
+ * which is the authoritative "page is under this category" signal.
99
+ * Falls back to URL-path matching when omitted (home, 404, tag, and
100
+ * version pages carry no section).
101
+ */
102
+ activeCategory?: string;
103
+
104
+ /**
105
+ * Children projected into the mobile `<SidebarToggle>` island —
106
+ * replaces the legacy `<slot name="sidebar" />`. Consumers pass the
107
+ * sidebar tree they want to surface in the mobile sheet.
108
+ */
109
+ sidebarSlot?: ComponentChildren;
110
+
111
+ /**
112
+ * Replacement for the `<SidebarToggle client:media="...">` element in
113
+ * the legacy template. Consumers wrap their preferred Preact /
114
+ * `<Island>` toggle (with the sidebar slot already nested inside) and
115
+ * the shell drops it in untouched. When omitted nothing renders in
116
+ * that slot — the layout is still valid (e.g. doc pages with
117
+ * `hide_sidebar`).
118
+ */
119
+ sidebarToggle?: ComponentChildren;
120
+
121
+ /**
122
+ * Replacement for `<ThemeToggle client:load />`. Rendered only when
123
+ * `colorModeEnabled` is `true` AND a `theme-toggle` entry survives
124
+ * `filterHeaderRightItems` — matching the original template.
125
+ */
126
+ themeToggle?: ComponentChildren;
127
+
128
+ /** Replacement for the `<LanguageSwitcher />` Astro child. */
129
+ languageSwitcher?: ComponentChildren;
130
+
131
+ /** Replacement for the `<VersionSwitcher />` Astro child. */
132
+ versionSwitcher?: ComponentChildren;
133
+
134
+ /** Replacement for the `<Search />` Astro child. */
135
+ search?: ComponentChildren;
136
+
137
+ /**
138
+ * When provided, emits `data-zfb-transition-persist={persistKey}` on the
139
+ * `<header>` element so zfb's client-router preserves DOM-node identity
140
+ * across same-locale View Transition swaps. Omit to disable persist
141
+ * (back-compat default — the header is re-rendered on every swap).
142
+ *
143
+ * **Locale keying**: callers MUST key by locale (e.g. `"header-en"`,
144
+ * `"header-ja"`). Cross-locale swaps must NOT share the same key; a
145
+ * key mismatch tells the router to replace the header, re-rendering
146
+ * the locale toggle anchors and all other SSR'd locale-specific
147
+ * content with fresh markup. See zudolab/zudo-doc#1546.
148
+ *
149
+ * **headerOverride scope**: hosts that supply their own `<header>`
150
+ * element via `headerOverride` on `<DocLayoutWithDefaults>` are
151
+ * responsible for adding `data-zfb-transition-persist` to their custom
152
+ * element themselves. This package only injects the attribute on the
153
+ * default `<Header>` shell. A key of `"header-${lang}"` is recommended
154
+ * for consistency, matching the Astro reference implementation
155
+ * (zudolab/zudo-doc#1546).
156
+ */
157
+ persistKey?: string;
158
+
159
+ /** Site-name string shown in the logo anchor (host `settings.siteName`). */
160
+ siteName: string;
161
+
162
+ /** Header-nav items in render order (host `settings.headerNav`). */
163
+ headerNav: HeaderNavItem[];
164
+
165
+ /**
166
+ * Header-right items, **already filtered** by the host via
167
+ * `filterHeaderRightItems` (see `./right-items.ts`). The renderer no
168
+ * longer re-checks `settings.colorMode` / `locales.length` etc — if
169
+ * an item is in this array, the renderer emits its markup. Slot
170
+ * presence (e.g. `languageSwitcher` being supplied) still gates per
171
+ * the legacy template: a slot left undefined renders an empty
172
+ * wrapper, matching prior behaviour.
173
+ */
174
+ headerRightItems: HeaderRightItem[];
175
+
176
+ /**
177
+ * Whether the host has a `colorMode` config. Required because the
178
+ * `theme-toggle` right-item is two-gated: the filter drops it when
179
+ * `colorMode` is off entirely, but the renderer also checks it before
180
+ * emitting the wrapping `<div>` so a `headerRightItems` array that
181
+ * still contains `theme-toggle` but is rendered against a config
182
+ * with `colorMode === false` stays a no-op (the legacy template's
183
+ * shape).
184
+ */
185
+ colorModeEnabled: boolean;
186
+
187
+ /**
188
+ * Whether the host has more than one locale configured (`locales.length > 1`
189
+ * in the legacy template's gate). The renderer needs this to keep
190
+ * the language-switcher slot empty on single-locale sites even when
191
+ * `headerRightItems` contains a `language-switcher` entry.
192
+ */
193
+ hasLocales: boolean;
194
+
195
+ /**
196
+ * Whether the host has `versions` configured. Currently informational —
197
+ * the version-switcher gate lives entirely in the host slot's
198
+ * presence — but accepted for symmetry with `colorModeEnabled` /
199
+ * `hasLocales` so the prop bag fully describes the feature surface.
200
+ */
201
+ hasVersions: boolean;
202
+
203
+ /**
204
+ * Resolved GitHub repo URL (no trailing slash) or `null` when
205
+ * unconfigured. Replaces the legacy direct call to
206
+ * `buildGitHubRepoUrl()` in this component.
207
+ */
208
+ githubRepoUrl: string | null;
209
+
210
+ /**
211
+ * Localised aria-label / title for the GitHub anchor — host-side
212
+ * translated string (the legacy `t("header.github", lang)`).
213
+ */
214
+ githubLabel: string;
215
+
216
+ /** URL builder helpers — see `HeaderUrlHelpers`. */
217
+ urlHelpers: HeaderUrlHelpers;
218
+
219
+ /** i18n helpers and config — see `HeaderI18n`. */
220
+ i18n: HeaderI18n;
221
+ }
222
+
223
+ /**
224
+ * Site-header shell — JSX port of `src/components/header`.
225
+ *
226
+ * Responsibilities (matching the legacy template byte-for-byte):
227
+ * 1. Render the sticky `<header>` with the site logo and the desktop
228
+ * nav, including the dropdown-parent / overflow-bucket markup the
229
+ * controller script reshapes at runtime.
230
+ * 2. Iterate `headerRightItems` (already filtered by the caller via
231
+ * `filterHeaderRightItems`), emitting the matching trigger button /
232
+ * icon link / consumer-supplied slot for each entry.
233
+ * 3. Emit the inline overflow controller script. The script wires the
234
+ * "..." overflow menu, manages `aria-expanded` on dropdowns, and
235
+ * re-runs after View Transitions (via `AFTER_NAVIGATE_EVENT` from
236
+ * `../transitions/page-events`).
237
+ */
238
+ export function Header(props: HeaderProps): JSX.Element {
239
+ const {
240
+ lang,
241
+ currentPath = "",
242
+ currentVersion,
243
+ activeCategory,
244
+ sidebarSlot,
245
+ sidebarToggle,
246
+ themeToggle,
247
+ languageSwitcher,
248
+ versionSwitcher,
249
+ search,
250
+ persistKey,
251
+ siteName,
252
+ headerNav,
253
+ headerRightItems,
254
+ colorModeEnabled,
255
+ hasLocales,
256
+ githubRepoUrl,
257
+ githubLabel,
258
+ urlHelpers,
259
+ i18n,
260
+ } = props;
261
+
262
+ const isNonDefaultLocale = lang != null && lang !== i18n.defaultLocale;
263
+ const pathWithoutBase = urlHelpers.stripBase(currentPath);
264
+ const matchPath = pathForMatch(pathWithoutBase, lang, i18n.defaultLocale);
265
+
266
+ const activeNavPath = computeActiveNavPath(headerNav, matchPath);
267
+
268
+ return (
269
+ <header
270
+ class="sticky top-0 z-toolbar flex h-[3.5rem] items-center border-b border-muted bg-surface px-hsp-lg"
271
+ data-header
272
+ // Strategy B persist (zudolab/zudo-doc#1546): the header now carries
273
+ // data-zfb-transition-persist when a locale-keyed persistKey is
274
+ // supplied (e.g. "header-en" / "header-ja"). Cross-locale swaps use
275
+ // different keys, so the router replaces the header element entirely,
276
+ // re-rendering the locale toggle anchors and all locale-specific SSR
277
+ // content with fresh markup. Same-locale swaps share the key and
278
+ // preserve DOM-node identity; each embedded element refreshes via
279
+ // AFTER_NAVIGATE_EVENT or URL derivation:
280
+ // - ThemeToggle: re-applies from localStorage on AFTER_NAVIGATE_EVENT
281
+ // (color-scheme-provider.tsx bootstrap script, #1546 verified (a))
282
+ // - VersionSwitcher: VERSION_SWITCHER_INIT_SCRIPT re-wires toggle on
283
+ // AFTER_NAVIGATE_EVENT (version-switcher.tsx:340, verified (a))
284
+ // - Search: <site-search> custom element re-registers on
285
+ // AFTER_NAVIGATE_EVENT (_search-widget-script.ts:184, verified (a))
286
+ // - SidebarToggle (mobile): closes on AFTER_NAVIGATE_EVENT
287
+ // (sidebar-toggle.tsx:72, verified (a); sidebar content is
288
+ // re-serialised into Island data-props on every SSR render, so
289
+ // same-locale swaps see stale SSR in the persist window but the
290
+ // Island re-hydrates with correct nodes on mount)
291
+ // - Header nav + aria-current: NAV_OVERFLOW_SCRIPT re-runs on
292
+ // AFTER_NAVIGATE_EVENT (nav-overflow-script.ts:198, verified (a))
293
+ // - LanguageSwitcher: SSR'd locale links are locale-static within
294
+ // a same-locale persist window — no per-page fields go stale
295
+ // (verified (a) — locale does not change during same-locale nav)
296
+ // Omit persistKey to fall back to the old repaint-on-every-swap path.
297
+ data-zfb-transition-persist={persistKey}
298
+ >
299
+ {sidebarToggle ?? (
300
+ // Render an inert wrapper so consumers that omit the slot still
301
+ // get the named-slot semantics from the legacy template
302
+ // (mobile sidebar contents stay accessible to assistive tech
303
+ // even without the toggle island).
304
+ <SidebarSlotFallback>{sidebarSlot}</SidebarSlotFallback>
305
+ )}
306
+
307
+ <a
308
+ href={urlHelpers.withBase(isNonDefaultLocale ? `/${lang}/` : "/")}
309
+ class="whitespace-nowrap text-title font-bold text-fg hover:underline focus:underline shrink-0"
310
+ data-header-logo
311
+ >
312
+ {siteName}
313
+ </a>
314
+
315
+ <nav
316
+ aria-label="Main"
317
+ class="relative ml-hsp-xl hidden min-w-0 flex-1 items-center gap-x-hsp-2xs whitespace-nowrap lg:flex"
318
+ data-header-nav
319
+ >
320
+ {headerNav.map((item) => renderNavItem(
321
+ item,
322
+ activeNavPath,
323
+ activeCategory,
324
+ lang,
325
+ currentVersion,
326
+ urlHelpers,
327
+ i18n,
328
+ ))}
329
+
330
+ <div class="relative shrink-0" data-nav-more style="display:none">
331
+ <button
332
+ type="button"
333
+ class="px-hsp-md py-vsp-2xs text-small font-medium text-muted hover:underline cursor-pointer"
334
+ data-nav-more-toggle
335
+ aria-expanded="false"
336
+ >
337
+ {"···"}
338
+ </button>
339
+ <ul
340
+ class="absolute right-0 top-full z-dropdown mt-vsp-3xs hidden min-w-[8rem] border border-muted rounded bg-surface shadow-lg whitespace-nowrap"
341
+ data-nav-more-menu
342
+ />
343
+ </div>
344
+ </nav>
345
+
346
+ <div
347
+ class="ml-auto flex shrink-0 items-center gap-x-hsp-md"
348
+ data-header-right
349
+ >
350
+ {headerRightItems.map((item, i) => renderRightItem(
351
+ item,
352
+ i,
353
+ {
354
+ lang,
355
+ githubRepoUrl,
356
+ githubLabel,
357
+ themeToggle,
358
+ languageSwitcher,
359
+ versionSwitcher,
360
+ search,
361
+ colorModeEnabled,
362
+ hasLocales,
363
+ },
364
+ ))}
365
+ </div>
366
+
367
+ <script dangerouslySetInnerHTML={{ __html: NAV_OVERFLOW_SCRIPT }} />
368
+ </header>
369
+ );
370
+ }
371
+
372
+ /** Visually-hidden fallback container so the `<slot name="sidebar" />`
373
+ * semantic is preserved even when the consumer doesn't pass an
374
+ * interactive `sidebarToggle` slot. Renders nothing visible — the
375
+ * legacy template only surfaces this content via the toggle island. */
376
+ function SidebarSlotFallback({
377
+ children,
378
+ }: {
379
+ children?: ComponentChildren;
380
+ }): VNode | null {
381
+ if (children === undefined || children === null) return null;
382
+ return <span hidden>{children}</span>;
383
+ }
384
+
385
+ function renderNavItem(
386
+ item: HeaderNavItem,
387
+ activeNavPath: string | undefined,
388
+ activeCategory: string | undefined,
389
+ lang: Locale | undefined,
390
+ currentVersion: string | undefined,
391
+ urlHelpers: HeaderUrlHelpers,
392
+ i18n: HeaderI18n,
393
+ ): VNode {
394
+ // Category matching (the page's resolved big category) is the primary
395
+ // signal; URL-path matching stays as a secondary fallback so items that
396
+ // declare no `categoryMatch`, and pages with no resolved section (home,
397
+ // 404, tag, version), still highlight as they did before.
398
+ const isActive =
399
+ isNavItemActiveByCategory(item, activeCategory) ||
400
+ isNavItemActive(item, activeNavPath);
401
+ const href = urlHelpers.navHref(item.path, lang, currentVersion);
402
+ const label = item.labelKey ? i18n.t(item.labelKey, lang) : item.label;
403
+
404
+ if (item.children && item.children.length > 0) {
405
+ return (
406
+ <div
407
+ class="group relative shrink-0"
408
+ data-nav-item
409
+ data-nav-item-dropdown
410
+ >
411
+ <a
412
+ href={href}
413
+ aria-current={isActive ? "page" : undefined}
414
+ aria-haspopup="true"
415
+ aria-expanded="false"
416
+ class={[
417
+ "flex items-center gap-x-hsp-xs px-hsp-md py-vsp-2xs text-small font-medium transition-colors",
418
+ isActive
419
+ ? "bg-fg text-bg"
420
+ : "text-muted hover:underline focus:underline",
421
+ ].join(" ")}
422
+ >
423
+ {label}
424
+ <svg
425
+ class={[
426
+ "h-[0.5rem] w-[0.5rem] shrink-0",
427
+ isActive ? "text-bg" : "text-muted",
428
+ ].join(" ")}
429
+ fill="none"
430
+ viewBox="0 0 24 24"
431
+ stroke="currentColor"
432
+ stroke-width="3"
433
+ aria-hidden="true"
434
+ >
435
+ <path
436
+ stroke-linecap="round"
437
+ stroke-linejoin="round"
438
+ d="M19 9l-7 7-7-7"
439
+ />
440
+ </svg>
441
+ </a>
442
+ <div class="absolute left-0 top-full z-dropdown hidden group-hover:block group-focus-within:block pt-vsp-3xs">
443
+ <div class="min-w-[10rem] border border-muted rounded bg-surface shadow-lg py-vsp-3xs">
444
+ {item.children.map((child) => {
445
+ const childHref = urlHelpers.navHref(child.path, lang, currentVersion);
446
+ const childLabel = child.labelKey
447
+ ? i18n.t(child.labelKey, lang)
448
+ : child.label;
449
+ const childActive =
450
+ isNavItemActiveByCategory(child, activeCategory) ||
451
+ activeNavPath === child.path;
452
+ return (
453
+ <a
454
+ href={childHref}
455
+ data-active={childActive ? "" : undefined}
456
+ class={[
457
+ "block px-hsp-md py-vsp-2xs text-small hover:bg-accent/10 hover:underline",
458
+ childActive ? "font-bold text-accent" : "text-fg",
459
+ ].join(" ")}
460
+ >
461
+ {childLabel}
462
+ </a>
463
+ );
464
+ })}
465
+ </div>
466
+ </div>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ return (
472
+ <a
473
+ href={href}
474
+ aria-current={isActive ? "page" : undefined}
475
+ data-nav-item
476
+ class={[
477
+ "px-hsp-md py-vsp-2xs text-small font-medium transition-colors shrink-0",
478
+ isActive
479
+ ? "bg-fg text-bg"
480
+ : "text-muted hover:underline focus:underline",
481
+ ].join(" ")}
482
+ >
483
+ {label}
484
+ </a>
485
+ );
486
+ }
487
+
488
+ interface RightItemContext {
489
+ lang: Locale | undefined;
490
+ githubRepoUrl: string | null;
491
+ githubLabel: string;
492
+ themeToggle: ComponentChildren;
493
+ languageSwitcher: ComponentChildren;
494
+ versionSwitcher: ComponentChildren;
495
+ search: ComponentChildren;
496
+ colorModeEnabled: boolean;
497
+ hasLocales: boolean;
498
+ }
499
+
500
+ /**
501
+ * Shared trigger-button shell for header-right items that dispatch a
502
+ * CustomEvent on click. The legacy template used an inline `onclick`
503
+ * attribute string; we preserve that DOM-level behaviour by spreading via
504
+ * a plain object — Preact's typed JSX rejects a string-valued `onclick`
505
+ * prop, but the renderer forwards the literal attribute through a spread.
506
+ */
507
+ function TriggerButton({
508
+ index,
509
+ id,
510
+ ariaLabel,
511
+ event,
512
+ children,
513
+ }: {
514
+ index: number;
515
+ id: string;
516
+ ariaLabel: string;
517
+ event: string;
518
+ children: ComponentChildren;
519
+ }): VNode {
520
+ const inlineOnclick: Record<string, string> = {
521
+ onclick: `window.dispatchEvent(new CustomEvent('${event}'))`,
522
+ };
523
+ return (
524
+ <button
525
+ key={`right-${index}`}
526
+ id={id}
527
+ type="button"
528
+ class="flex items-center justify-center text-muted transition-colors hover:text-fg"
529
+ aria-label={ariaLabel}
530
+ {...inlineOnclick}
531
+ >
532
+ {children}
533
+ </button>
534
+ );
535
+ }
536
+
537
+ /**
538
+ * Shared wrapper div for header-right component slots. Pass `className`
539
+ * to apply Tailwind classes; omit it entirely (or pass `undefined`) for
540
+ * the plain `<div>` variant (search slot has no extra class).
541
+ */
542
+ function SlotWrapper({
543
+ index,
544
+ className,
545
+ children,
546
+ }: {
547
+ index: number;
548
+ className?: string;
549
+ children: ComponentChildren;
550
+ }): VNode {
551
+ return (
552
+ <div key={`right-${index}`} class={className}>
553
+ {children}
554
+ </div>
555
+ );
556
+ }
557
+
558
+ type RightItemHandler = (
559
+ item: HeaderRightItem,
560
+ index: number,
561
+ ctx: RightItemContext,
562
+ ) => VNode | null;
563
+
564
+ // Dispatch table keyed by `${type}:${trigger|component}`, or just `type`
565
+ // for link/html items that carry no sub-type discriminant.
566
+ const RIGHT_ITEM_DISPATCH: Record<string, RightItemHandler> = {
567
+ "trigger:design-token-panel": (_item, index) => (
568
+ <TriggerButton
569
+ index={index}
570
+ id="design-token-trigger"
571
+ ariaLabel="Toggle design token panel"
572
+ event="toggle-design-token-panel"
573
+ >
574
+ <svg
575
+ xmlns="http://www.w3.org/2000/svg"
576
+ width="20"
577
+ height="20"
578
+ viewBox="0 0 24 24"
579
+ fill="none"
580
+ stroke="currentColor"
581
+ stroke-width="2"
582
+ stroke-linecap="round"
583
+ stroke-linejoin="round"
584
+ aria-hidden="true"
585
+ >
586
+ <circle cx="13.5" cy="6.5" r="2.5" />
587
+ <circle cx="17.5" cy="10.5" r="2.5" />
588
+ <circle cx="8.5" cy="7.5" r="2.5" />
589
+ <circle cx="6.5" cy="12.5" r="2.5" />
590
+ <path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
591
+ </svg>
592
+ </TriggerButton>
593
+ ),
594
+
595
+ "trigger:ai-chat": (_item, index) => (
596
+ <TriggerButton
597
+ index={index}
598
+ id="ai-chat-trigger"
599
+ ariaLabel="Open AI assistant"
600
+ event="toggle-ai-chat"
601
+ >
602
+ <svg
603
+ xmlns="http://www.w3.org/2000/svg"
604
+ width="20"
605
+ height="20"
606
+ viewBox="0 0 24 24"
607
+ fill="currentColor"
608
+ aria-hidden="true"
609
+ >
610
+ <path d="M9.5 2.5Q10.5 11.5 18 13Q10.5 14.5 9.5 23.5Q8.5 14.5 1 13Q8.5 11.5 9.5 2.5Z" />
611
+ <path d="M19 0.5Q19.5 4 23.5 5Q19.5 6 19 9.5Q18.5 6 14.5 5Q18.5 4 19 0.5Z" />
612
+ </svg>
613
+ </TriggerButton>
614
+ ),
615
+
616
+ "component:version-switcher": (_item, index, ctx) => (
617
+ <SlotWrapper index={index} className="hidden lg:block">
618
+ {ctx.versionSwitcher}
619
+ </SlotWrapper>
620
+ ),
621
+
622
+ "component:github-link": (_item, index, ctx) => {
623
+ if (!ctx.githubRepoUrl) return null;
624
+ return (
625
+ <a
626
+ key={`right-${index}`}
627
+ href={ctx.githubRepoUrl}
628
+ target="_blank"
629
+ rel="noopener noreferrer"
630
+ class="flex items-center justify-center text-muted transition-colors hover:text-fg"
631
+ aria-label={ctx.githubLabel}
632
+ title={ctx.githubLabel}
633
+ >
634
+ <span class="sr-only">{ctx.githubLabel}</span>
635
+ <GitHubIcon />
636
+ </a>
637
+ );
638
+ },
639
+
640
+ "component:theme-toggle": (_item, index, ctx) => {
641
+ // Mirrors the legacy template's two-gate behaviour: the
642
+ // `filterHeaderRightItems` caller drops this item entirely when
643
+ // color-mode is off, but the renderer still cross-checks the host
644
+ // flag so an inconsistent caller (item present + colorMode off)
645
+ // stays a no-op instead of emitting an empty island slot.
646
+ if (!ctx.colorModeEnabled) return null;
647
+ return (
648
+ <SlotWrapper index={index} className="hidden lg:flex items-center">
649
+ {ctx.themeToggle}
650
+ </SlotWrapper>
651
+ );
652
+ },
653
+
654
+ "component:language-switcher": (_item, index, ctx) => {
655
+ // Same two-gate shape as theme-toggle above. The legacy template
656
+ // gated on `lang && locales.length > 1`; the host signals the
657
+ // multi-locale half via `hasLocales`, and the `lang` half is still
658
+ // checked here so single-locale renders (where `lang` is undefined)
659
+ // emit nothing.
660
+ if (!(ctx.lang && ctx.hasLocales)) return null;
661
+ return (
662
+ <SlotWrapper index={index} className="hidden lg:flex items-center">
663
+ {ctx.languageSwitcher}
664
+ </SlotWrapper>
665
+ );
666
+ },
667
+
668
+ "component:search": (_item, index, ctx) => (
669
+ <SlotWrapper index={index}>{ctx.search}</SlotWrapper>
670
+ ),
671
+
672
+ link: (item, index) => {
673
+ if (item.type !== "link") return null;
674
+ const label = item.label ?? item.ariaLabel;
675
+ const isExternal = /^https?:\/\//.test(item.href);
676
+ return (
677
+ <a
678
+ key={`right-${index}`}
679
+ href={item.href}
680
+ target={isExternal ? "_blank" : undefined}
681
+ rel={isExternal ? "noopener noreferrer" : undefined}
682
+ class="flex items-center justify-center text-muted transition-colors hover:text-fg"
683
+ aria-label={item.ariaLabel}
684
+ title={label}
685
+ >
686
+ {item.icon === "github" ? (
687
+ <>
688
+ {label && <span class="sr-only">{label}</span>}
689
+ <GitHubIcon />
690
+ </>
691
+ ) : (
692
+ label
693
+ )}
694
+ </a>
695
+ );
696
+ },
697
+
698
+ html: (item, index) => {
699
+ if (item.type !== "html") return null;
700
+ return (
701
+ <span
702
+ key={`right-${index}`}
703
+ // Mirrors `<Fragment set:html={item.html} />` from the Astro
704
+ // template — the legacy code already trusts this string, and
705
+ // this port preserves that contract.
706
+ dangerouslySetInnerHTML={{ __html: item.html }}
707
+ />
708
+ );
709
+ },
710
+ };
711
+
712
+ function renderRightItem(
713
+ item: HeaderRightItem,
714
+ index: number,
715
+ ctx: RightItemContext,
716
+ ): VNode | null {
717
+ const key =
718
+ item.type === "trigger"
719
+ ? `trigger:${item.trigger}`
720
+ : item.type === "component"
721
+ ? `component:${item.component}`
722
+ : item.type;
723
+ const handler = RIGHT_ITEM_DISPATCH[key];
724
+ return handler ? handler(item, index, ctx) : null;
725
+ }