create-zudo-doc 0.1.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 (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/create-zudo-doc.js +2 -0
  4. package/dist/api.d.ts +20 -0
  5. package/dist/api.js +13 -0
  6. package/dist/claude-md-gen.d.ts +2 -0
  7. package/dist/claude-md-gen.js +113 -0
  8. package/dist/cli.d.ts +39 -0
  9. package/dist/cli.js +157 -0
  10. package/dist/compose.d.ts +95 -0
  11. package/dist/compose.js +206 -0
  12. package/dist/constants.d.ts +20 -0
  13. package/dist/constants.js +224 -0
  14. package/dist/features/body-foot-util.d.ts +10 -0
  15. package/dist/features/body-foot-util.js +12 -0
  16. package/dist/features/claude-resources.d.ts +2 -0
  17. package/dist/features/claude-resources.js +6 -0
  18. package/dist/features/design-token-panel.d.ts +14 -0
  19. package/dist/features/design-token-panel.js +27 -0
  20. package/dist/features/doc-history.d.ts +9 -0
  21. package/dist/features/doc-history.js +11 -0
  22. package/dist/features/doc-tags.d.ts +19 -0
  23. package/dist/features/doc-tags.js +33 -0
  24. package/dist/features/footer-taglist.d.ts +14 -0
  25. package/dist/features/footer-taglist.js +17 -0
  26. package/dist/features/footer.d.ts +8 -0
  27. package/dist/features/footer.js +10 -0
  28. package/dist/features/i18n.d.ts +22 -0
  29. package/dist/features/i18n.js +41 -0
  30. package/dist/features/image-enlarge.d.ts +11 -0
  31. package/dist/features/image-enlarge.js +13 -0
  32. package/dist/features/index.d.ts +15 -0
  33. package/dist/features/index.js +53 -0
  34. package/dist/features/llms-txt.d.ts +11 -0
  35. package/dist/features/llms-txt.js +13 -0
  36. package/dist/features/search.d.ts +9 -0
  37. package/dist/features/search.js +11 -0
  38. package/dist/features/sidebar-resizer.d.ts +14 -0
  39. package/dist/features/sidebar-resizer.js +16 -0
  40. package/dist/features/sidebar-toggle.d.ts +13 -0
  41. package/dist/features/sidebar-toggle.js +15 -0
  42. package/dist/features/tag-governance.d.ts +14 -0
  43. package/dist/features/tag-governance.js +16 -0
  44. package/dist/features/tauri-dev.d.ts +2 -0
  45. package/dist/features/tauri-dev.js +25 -0
  46. package/dist/features/tauri.d.ts +11 -0
  47. package/dist/features/tauri.js +52 -0
  48. package/dist/features/versioning.d.ts +27 -0
  49. package/dist/features/versioning.js +43 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +150 -0
  52. package/dist/preset.d.ts +37 -0
  53. package/dist/preset.js +156 -0
  54. package/dist/prompts.d.ts +32 -0
  55. package/dist/prompts.js +248 -0
  56. package/dist/scaffold.d.ts +4 -0
  57. package/dist/scaffold.js +344 -0
  58. package/dist/settings-gen.d.ts +2 -0
  59. package/dist/settings-gen.js +237 -0
  60. package/dist/utils.d.ts +8 -0
  61. package/dist/utils.js +34 -0
  62. package/dist/zfb-config-gen.d.ts +19 -0
  63. package/dist/zfb-config-gen.js +222 -0
  64. package/package.json +65 -0
  65. package/templates/base/.htmlvalidate.json +5 -0
  66. package/templates/base/.zfb/doc-history-meta.json +1 -0
  67. package/templates/base/pages/404.tsx +55 -0
  68. package/templates/base/pages/_data.ts +179 -0
  69. package/templates/base/pages/_mdx-components.ts +249 -0
  70. package/templates/base/pages/docs/[...slug].tsx +448 -0
  71. package/templates/base/pages/index.tsx +158 -0
  72. package/templates/base/pages/lib/_body-end-islands.tsx +201 -0
  73. package/templates/base/pages/lib/_category-nav.tsx +148 -0
  74. package/templates/base/pages/lib/_category-tree-nav.tsx +104 -0
  75. package/templates/base/pages/lib/_compose-meta-title.ts +29 -0
  76. package/templates/base/pages/lib/_details.tsx +30 -0
  77. package/templates/base/pages/lib/_doc-history-area.tsx +178 -0
  78. package/templates/base/pages/lib/_doc-metainfo-area.tsx +100 -0
  79. package/templates/base/pages/lib/_doc-tags-area.tsx +89 -0
  80. package/templates/base/pages/lib/_extract-headings.ts +81 -0
  81. package/templates/base/pages/lib/_footer-with-defaults.tsx +234 -0
  82. package/templates/base/pages/lib/_frontmatter-preview-data.ts +53 -0
  83. package/templates/base/pages/lib/_head-with-defaults.tsx +113 -0
  84. package/templates/base/pages/lib/_header-with-defaults.tsx +386 -0
  85. package/templates/base/pages/lib/_inline-version-switcher.tsx +84 -0
  86. package/templates/base/pages/lib/_math-block.tsx +63 -0
  87. package/templates/base/pages/lib/_nav-source-docs.ts +68 -0
  88. package/templates/base/pages/lib/_preset-generator.tsx +81 -0
  89. package/templates/base/pages/lib/_search-widget-script.ts +388 -0
  90. package/templates/base/pages/lib/_search-widget.tsx +196 -0
  91. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +176 -0
  92. package/templates/base/pages/lib/_site-tree-nav.tsx +128 -0
  93. package/templates/base/pages/lib/locale-merge.ts +58 -0
  94. package/templates/base/pages/lib/route-enumerators.ts +302 -0
  95. package/templates/base/pages/sitemap.xml.tsx +51 -0
  96. package/templates/base/plugins/connect-adapter.mjs +144 -0
  97. package/templates/base/plugins/copy-public-plugin.mjs +50 -0
  98. package/templates/base/plugins/search-index-plugin.mjs +54 -0
  99. package/templates/base/scripts/run-b4push.sh +102 -0
  100. package/templates/base/src/components/ai-chat-modal.tsx +15 -0
  101. package/templates/base/src/components/client-router-bootstrap.tsx +14 -0
  102. package/templates/base/src/components/content/component-map.ts +25 -0
  103. package/templates/base/src/components/content/content-blockquote.tsx +16 -0
  104. package/templates/base/src/components/content/content-code.tsx +117 -0
  105. package/templates/base/src/components/content/content-link.tsx +83 -0
  106. package/templates/base/src/components/content/content-ol.tsx +19 -0
  107. package/templates/base/src/components/content/content-paragraph.tsx +10 -0
  108. package/templates/base/src/components/content/content-strong.tsx +16 -0
  109. package/templates/base/src/components/content/content-table.tsx +18 -0
  110. package/templates/base/src/components/content/content-ul.tsx +18 -0
  111. package/templates/base/src/components/content/heading-h2.tsx +26 -0
  112. package/templates/base/src/components/content/heading-h3.tsx +26 -0
  113. package/templates/base/src/components/content/heading-h4.tsx +26 -0
  114. package/templates/base/src/components/design-token-panel-bootstrap.tsx +15 -0
  115. package/templates/base/src/components/desktop-sidebar-toggle.tsx +15 -0
  116. package/templates/base/src/components/doc-history.tsx +18 -0
  117. package/templates/base/src/components/html-preview/highlighted-code.tsx +74 -0
  118. package/templates/base/src/components/html-preview/html-preview.tsx +108 -0
  119. package/templates/base/src/components/html-preview/preflight.ts +112 -0
  120. package/templates/base/src/components/html-preview/preview-base.tsx +159 -0
  121. package/templates/base/src/components/image-enlarge.tsx +19 -0
  122. package/templates/base/src/components/mobile-toc.tsx +94 -0
  123. package/templates/base/src/components/preset-generator.tsx +14 -0
  124. package/templates/base/src/components/sidebar-toggle.tsx +98 -0
  125. package/templates/base/src/components/sidebar-tree.tsx +543 -0
  126. package/templates/base/src/components/site-tree-nav.tsx +233 -0
  127. package/templates/base/src/components/theme-toggle.tsx +93 -0
  128. package/templates/base/src/components/toc.tsx +63 -0
  129. package/templates/base/src/components/tree-nav-shared.tsx +71 -0
  130. package/templates/base/src/config/color-scheme-utils.ts +182 -0
  131. package/templates/base/src/config/color-schemes.ts +128 -0
  132. package/templates/base/src/config/frontmatter-preview-defaults.ts +24 -0
  133. package/templates/base/src/config/frontmatter-preview-renderers.tsx +46 -0
  134. package/templates/base/src/config/i18n.ts +225 -0
  135. package/templates/base/src/config/settings-types.ts +162 -0
  136. package/templates/base/src/config/sidebars.ts +66 -0
  137. package/templates/base/src/config/tag-vocabulary-types.ts +39 -0
  138. package/templates/base/src/config/tag-vocabulary.ts +20 -0
  139. package/templates/base/src/hooks/use-active-heading.ts +133 -0
  140. package/templates/base/src/plugins/docs-source-map.ts +103 -0
  141. package/templates/base/src/plugins/hast-utils.ts +10 -0
  142. package/templates/base/src/plugins/rehype-code-title.ts +50 -0
  143. package/templates/base/src/plugins/rehype-heading-links.ts +53 -0
  144. package/templates/base/src/plugins/rehype-image-enlarge.ts +113 -0
  145. package/templates/base/src/plugins/rehype-mermaid.ts +41 -0
  146. package/templates/base/src/plugins/rehype-strip-md-extension.ts +58 -0
  147. package/templates/base/src/plugins/remark-admonitions.ts +99 -0
  148. package/templates/base/src/plugins/remark-resolve-markdown-links.ts +127 -0
  149. package/templates/base/src/plugins/url-utils.ts +4 -0
  150. package/templates/base/src/styles/global.css +1066 -0
  151. package/templates/base/src/types/docs-entry.ts +39 -0
  152. package/templates/base/src/types/heading.ts +5 -0
  153. package/templates/base/src/types/locale.ts +10 -0
  154. package/templates/base/src/utils/base.ts +139 -0
  155. package/templates/base/src/utils/content-files.ts +106 -0
  156. package/templates/base/src/utils/dedent.ts +24 -0
  157. package/templates/base/src/utils/docs.ts +335 -0
  158. package/templates/base/src/utils/git-info.ts +70 -0
  159. package/templates/base/src/utils/github.ts +19 -0
  160. package/templates/base/src/utils/header-right-items.ts +38 -0
  161. package/templates/base/src/utils/nav-scope.ts +63 -0
  162. package/templates/base/src/utils/sidebar.ts +104 -0
  163. package/templates/base/src/utils/slug.ts +10 -0
  164. package/templates/base/src/utils/smart-break.tsx +126 -0
  165. package/templates/base/src/utils/tags.ts +126 -0
  166. package/templates/base/tsconfig.json +36 -0
  167. package/templates/features/bodyFootUtil/files/src/utils/github.ts +19 -0
  168. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +137 -0
  169. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/escape-for-mdx.test.ts +34 -0
  170. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +376 -0
  171. package/templates/features/claudeResources/files/src/integrations/claude-resources/escape-for-mdx.ts +93 -0
  172. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +586 -0
  173. package/templates/features/designTokenPanel/files/src/components/design-token-panel-bootstrap.tsx +15 -0
  174. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +99 -0
  175. package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +177 -0
  176. package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +50 -0
  177. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +99 -0
  178. package/templates/features/docHistory/files/src/components/doc-history.tsx +598 -0
  179. package/templates/features/docHistory/files/src/types/doc-history.ts +23 -0
  180. package/templates/features/docHistory/files/src/utils/doc-history.ts +180 -0
  181. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +116 -0
  182. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +99 -0
  183. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +101 -0
  184. package/templates/features/docTags/files/pages/docs/tags/index.tsx +86 -0
  185. package/templates/features/i18n/files/pages/[locale]/docs/[...slug].tsx +467 -0
  186. package/templates/features/i18n/files/pages/[locale]/index.tsx +213 -0
  187. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +248 -0
  188. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +74 -0
  189. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +185 -0
  190. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +126 -0
  191. package/templates/features/tagGovernance/files/scripts/tags-audit.ts +576 -0
  192. package/templates/features/tagGovernance/files/scripts/tags-suggest.ts +428 -0
  193. package/templates/features/tauri/files/src/components/find-bar.tsx +122 -0
  194. package/templates/features/tauri/files/src/components/find-in-page-init.tsx +53 -0
  195. package/templates/features/tauri/files/src/utils/find-in-page.ts +175 -0
  196. package/templates/features/tauri/files/src-tauri/Cargo.toml +14 -0
  197. package/templates/features/tauri/files/src-tauri/build.rs +3 -0
  198. package/templates/features/tauri/files/src-tauri/capabilities/default.json +11 -0
  199. package/templates/features/tauri/files/src-tauri/src/main.rs +250 -0
  200. package/templates/features/tauri/files/src-tauri/tauri.conf.json +25 -0
  201. package/templates/features/tauriDev/files/src-tauri-dev/Cargo.toml +15 -0
  202. package/templates/features/tauriDev/files/src-tauri-dev/build.rs +3 -0
  203. package/templates/features/tauriDev/files/src-tauri-dev/capabilities/default.json +7 -0
  204. package/templates/features/tauriDev/files/src-tauri-dev/frontend/index.html +187 -0
  205. package/templates/features/tauriDev/files/src-tauri-dev/icons/icon.png +0 -0
  206. package/templates/features/tauriDev/files/src-tauri-dev/src/main.rs +995 -0
  207. package/templates/features/tauriDev/files/src-tauri-dev/tauri.conf.json +22 -0
  208. package/templates/features/tauriDev/files/src-tauri-dev/test-launch.sh +65 -0
  209. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +100 -0
  210. package/templates/features/versioning/files/pages/docs/versions.tsx +78 -0
  211. package/templates/features/versioning/files/pages/v/[version]/docs/[...slug].tsx +451 -0
  212. package/templates/features/versioning/files/pages/v/[version]/ja/docs/[...slug].tsx +490 -0
@@ -0,0 +1,543 @@
1
+ "use client";
2
+
3
+ // Use preact hook entrypoints directly — zfb's esbuild step doesn't alias
4
+ // "react" to "preact/compat" the way Astro's `@astrojs/preact` integration
5
+ // did, so importing from "react" here would fail to resolve at SSR/island
6
+ // bundle time.
7
+ import { useState, useCallback, useEffect, useMemo, useRef } from "preact/hooks";
8
+ import type { NavNode } from "@/utils/docs";
9
+ import type { LocaleLink } from "@/types/locale";
10
+ import { INDENT, BASE_PAD, connectorLeft, ConnectorLines, CategoryLinkIcon } from "./tree-nav-shared";
11
+ import ThemeToggle from "@/components/theme-toggle";
12
+ import { smartBreakToHtml } from "@/utils/smart-break";
13
+
14
+ function ToggleChevron({ isExpanded, className }: { isExpanded: boolean; className?: string }) {
15
+ return (
16
+ <svg
17
+ xmlns="http://www.w3.org/2000/svg"
18
+ className={`h-[0.625rem] w-[0.625rem] shrink-0 transition-transform duration-150 ${isExpanded ? "rotate-90" : ""} ${className ?? ""}`}
19
+ fill="none"
20
+ viewBox="0 0 24 24"
21
+ stroke="currentColor"
22
+ strokeWidth={2}
23
+ aria-hidden="true"
24
+ >
25
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
26
+ </svg>
27
+ );
28
+ }
29
+
30
+ const STORAGE_KEY = "zd-sidebar-open";
31
+
32
+ function padLeft(depth: number, forCategory: boolean): string {
33
+ if (depth === 0) return `calc(${BASE_PAD} + ${forCategory ? "0.15rem" : "0rem"})`;
34
+ return `calc(${depth} * ${INDENT} + 1.25rem + 5px)`;
35
+ }
36
+
37
+ function getOpenSet(): Set<string> {
38
+ try {
39
+ const raw = sessionStorage.getItem(STORAGE_KEY);
40
+ if (!raw) return new Set();
41
+ const parsed: unknown = JSON.parse(raw);
42
+ return Array.isArray(parsed) ? new Set(parsed.filter((v): v is string => typeof v === "string")) : new Set();
43
+ } catch {
44
+ return new Set();
45
+ }
46
+ }
47
+
48
+ function saveOpenSet(set: Set<string>) {
49
+ try {
50
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...set]));
51
+ } catch {
52
+ // ignore
53
+ }
54
+ }
55
+
56
+ function normalizePath(p: string): string {
57
+ return p.replace(/\/$/, "") || "/";
58
+ }
59
+
60
+ /** Find the slug of the node whose href matches the given pathname */
61
+ function findActiveSlug(nodes: NavNode[], pathname: string): string | undefined {
62
+ for (const node of nodes) {
63
+ if (node.href && normalizePath(node.href) === pathname) return node.slug;
64
+ const found = findActiveSlug(node.children, pathname);
65
+ if (found) return found;
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /** Track current active slug, updating on View Transition navigations */
71
+ function useActiveSlug(nodes: NavNode[], initial?: string): string | undefined {
72
+ const [slug, setSlug] = useState(initial);
73
+
74
+ useEffect(() => {
75
+ const update = () => {
76
+ const pathname = normalizePath(window.location.pathname);
77
+ const found = findActiveSlug(nodes, pathname);
78
+ if (found !== undefined) setSlug(found);
79
+ };
80
+ update();
81
+ // zfb's `<ViewTransitions />` does a real page load on every
82
+ // navigation, so `DOMContentLoaded` is the post-navigate signal.
83
+ document.addEventListener("DOMContentLoaded", update);
84
+ return () => document.removeEventListener("DOMContentLoaded", update);
85
+ }, [nodes]);
86
+
87
+ return slug;
88
+ }
89
+
90
+ function filterTree(nodes: NavNode[], query: string): NavNode[] {
91
+ return nodes.reduce<NavNode[]>((acc, node) => {
92
+ const matchesLabel = node.label.toLowerCase().includes(query.toLowerCase());
93
+ const filteredChildren = node.children.length > 0
94
+ ? filterTree(node.children, query)
95
+ : [];
96
+
97
+ if (matchesLabel || filteredChildren.length > 0) {
98
+ acc.push({
99
+ ...node,
100
+ children: matchesLabel ? node.children : filteredChildren,
101
+ });
102
+ }
103
+ return acc;
104
+ }, []);
105
+ }
106
+
107
+ interface RootMenuItem {
108
+ label: string;
109
+ href: string;
110
+ children?: RootMenuItem[];
111
+ }
112
+
113
+ function RootMenuItemEntry({ item }: { item: RootMenuItem }) {
114
+ const [expanded, setExpanded] = useState(false);
115
+ const hasChildren = item.children && item.children.length > 0;
116
+
117
+ return (
118
+ <div className="border-t border-muted">
119
+ <div className="flex items-center">
120
+ <a
121
+ href={item.href}
122
+ className="flex flex-1 items-center gap-hsp-xs px-hsp-sm py-vsp-xs text-small font-semibold text-fg hover:text-accent hover:underline break-words"
123
+ >
124
+ <CategoryLinkIcon className="w-[14px]" />
125
+ <span dangerouslySetInnerHTML={{ __html: smartBreakToHtml(item.label) }} />
126
+ </a>
127
+ {hasChildren && (
128
+ <button
129
+ type="button"
130
+ onClick={() => setExpanded((prev) => !prev)}
131
+ className="flex items-center justify-center px-hsp-sm py-vsp-xs text-muted hover:text-fg"
132
+ aria-expanded={expanded}
133
+ aria-label={expanded ? `Collapse ${item.label}` : `Expand ${item.label}`}
134
+ >
135
+ <ToggleChevron isExpanded={expanded} className="text-muted" />
136
+ </button>
137
+ )}
138
+ </div>
139
+ {hasChildren && expanded && (
140
+ <div className="pb-vsp-xs">
141
+ {item.children!.map((child) => (
142
+ <a
143
+ key={child.href}
144
+ href={child.href}
145
+ className="block pl-hsp-xl pr-hsp-sm py-vsp-2xs text-small text-muted hover:text-accent hover:underline break-words"
146
+ >
147
+ <span dangerouslySetInnerHTML={{ __html: smartBreakToHtml(child.label) }} />
148
+ </a>
149
+ ))}
150
+ </div>
151
+ )}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ interface SidebarTreeProps {
157
+ nodes: NavNode[];
158
+ currentSlug?: string;
159
+ rootMenuItems?: RootMenuItem[];
160
+ backToMenuLabel?: string;
161
+ localeLinks?: LocaleLink[];
162
+ themeDefaultMode?: "light" | "dark";
163
+ }
164
+
165
+ function SidebarFooter({ links, themeDefaultMode }: { links?: LocaleLink[]; themeDefaultMode?: "light" | "dark" }) {
166
+ if (!links && !themeDefaultMode) return null;
167
+ return (
168
+ // pb-[50vh] provides scroll room so the footer doesn't sit at the very bottom of the viewport
169
+ <div className="lg:hidden flex items-center gap-hsp-md border-t border-muted px-hsp-sm py-vsp-xs pb-[50vh] text-small">
170
+ {themeDefaultMode && <ThemeToggle defaultMode={themeDefaultMode} />}
171
+ {links && links.map((link, i) => (
172
+ <span key={link.href} className="flex items-center gap-hsp-xs">
173
+ {i > 0 && <span className="text-muted">/</span>}
174
+ {link.active ? (
175
+ <span aria-current="true" className="font-medium text-fg">{link.label}</span>
176
+ ) : (
177
+ <a href={link.href} lang={link.code} className="text-muted hover:text-fg">
178
+ {link.label}
179
+ </a>
180
+ )}
181
+ </span>
182
+ ))}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ export default function SidebarTree({ nodes, currentSlug, rootMenuItems, backToMenuLabel, localeLinks, themeDefaultMode }: SidebarTreeProps) {
188
+ const activeSlug = useActiveSlug(nodes, currentSlug);
189
+ const [query, setQuery] = useState("");
190
+ const [showingRootMenu, setShowingRootMenu] = useState(false);
191
+ const filterRef = useRef<HTMLInputElement>(null);
192
+ const [filterPlaceholder, setFilterPlaceholder] = useState("Filter...");
193
+
194
+ // Detect OS to show appropriate keyboard shortcut in placeholder
195
+ useEffect(() => {
196
+ const platform = (navigator as { userAgentData?: { platform: string } }).userAgentData?.platform ?? navigator.platform;
197
+ const isMac = /mac/i.test(platform);
198
+ setFilterPlaceholder(isMac ? "Filter... (\u2318 + /)" : "Filter... (Ctrl + /)");
199
+ }, []);
200
+
201
+ // Global shortcut: Cmd+/ (Mac) or Ctrl+/ to focus the filter input
202
+ useEffect(() => {
203
+ function handleKeyDown(e: KeyboardEvent) {
204
+ if (e.isComposing) return;
205
+ if (e.key === "/" && (e.metaKey || e.ctrlKey)) {
206
+ const el = filterRef.current;
207
+ if (!el || el.offsetParent === null) return; // skip if hidden
208
+ e.preventDefault();
209
+ el.focus();
210
+ el.select();
211
+ }
212
+ }
213
+ document.addEventListener("keydown", handleKeyDown);
214
+ return () => document.removeEventListener("keydown", handleKeyDown);
215
+ }, []);
216
+
217
+ const filteredNodes = useMemo(
218
+ () => (query ? filterTree(nodes, query) : nodes),
219
+ [nodes, query],
220
+ );
221
+
222
+ const footer = useMemo(
223
+ () => (localeLinks || themeDefaultMode) ? <SidebarFooter links={localeLinks} themeDefaultMode={themeDefaultMode} /> : null,
224
+ [localeLinks, themeDefaultMode],
225
+ );
226
+
227
+ // Root menu view: show headerNav items as a simple list (Docusaurus-style)
228
+ if (showingRootMenu && rootMenuItems) {
229
+ return (
230
+ <nav>
231
+ <button
232
+ type="button"
233
+ onClick={() => setShowingRootMenu(false)}
234
+ className="flex w-full items-center gap-hsp-xs px-hsp-sm py-vsp-xs text-left text-small text-muted hover:text-fg border-b border-muted"
235
+ >
236
+ <svg className="h-icon-sm w-icon-sm shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
237
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
238
+ </svg>
239
+ {backToMenuLabel ?? "Back to main menu"}
240
+ </button>
241
+ {rootMenuItems.map((item) => (
242
+ <RootMenuItemEntry key={item.href} item={item} />
243
+ ))}
244
+ {footer}
245
+ </nav>
246
+ );
247
+ }
248
+
249
+ // Top page: show only header nav links, no doc tree or filter.
250
+ // Derived from activeSlug (runtime-synced) so it stays correct across View Transitions.
251
+ if (!activeSlug && rootMenuItems) {
252
+ return (
253
+ <nav>
254
+ {rootMenuItems.map((item) => (
255
+ <RootMenuItemEntry key={item.href} item={item} />
256
+ ))}
257
+ {footer}
258
+ </nav>
259
+ );
260
+ }
261
+
262
+ return (
263
+ <nav>
264
+ {rootMenuItems && (
265
+ <button
266
+ type="button"
267
+ onClick={() => setShowingRootMenu(true)}
268
+ className="lg:hidden flex w-full items-center gap-hsp-xs px-hsp-sm py-vsp-xs text-left text-small text-muted hover:text-fg border-b border-muted"
269
+ >
270
+ <svg className="h-icon-sm w-icon-sm shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
271
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
272
+ </svg>
273
+ {backToMenuLabel ?? "Back to main menu"}
274
+ </button>
275
+ )}
276
+ <div className="px-hsp-sm py-vsp-xs">
277
+ <div className="flex items-center gap-hsp-xs bg-surface rounded px-hsp-sm py-vsp-2xs">
278
+ <svg className="h-[14px] w-[14px] text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
279
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
280
+ </svg>
281
+ <input
282
+ ref={filterRef}
283
+ type="text"
284
+ placeholder={filterPlaceholder}
285
+ value={query}
286
+ onChange={(e) => setQuery(e.target.value)}
287
+ className="bg-transparent text-small outline-none w-full text-fg placeholder:text-muted"
288
+ />
289
+ </div>
290
+ </div>
291
+ <NodeList
292
+ nodes={filteredNodes}
293
+ currentSlug={activeSlug}
294
+ depth={0}
295
+ forceOpen={!!query}
296
+ />
297
+ {footer}
298
+ </nav>
299
+ );
300
+ }
301
+
302
+ function NodeList({
303
+ nodes,
304
+ currentSlug,
305
+ depth,
306
+ forceOpen,
307
+ }: {
308
+ nodes: NavNode[];
309
+ currentSlug?: string;
310
+ depth: number;
311
+ forceOpen: boolean;
312
+ }) {
313
+ return (
314
+ <>
315
+ {nodes.map((node, index) => {
316
+ const isLast = index === nodes.length - 1;
317
+ return node.children.length > 0 ? (
318
+ <CategoryNode
319
+ key={node.slug}
320
+ node={node}
321
+ currentSlug={currentSlug}
322
+ depth={depth}
323
+ isLast={isLast}
324
+ forceOpen={forceOpen}
325
+ />
326
+ ) : (
327
+ <LeafNode
328
+ key={node.slug}
329
+ node={node}
330
+ currentSlug={currentSlug}
331
+ depth={depth}
332
+ isLast={isLast}
333
+ />
334
+ );
335
+ })}
336
+ </>
337
+ );
338
+ }
339
+
340
+ /** Check if currentSlug is anywhere in this node's subtree */
341
+ function subtreeContainsSlug(node: NavNode, slug?: string): boolean {
342
+ if (!slug) return false;
343
+ if (node.slug === slug) return true;
344
+ return node.children.some((child) => subtreeContainsSlug(child, slug));
345
+ }
346
+
347
+ function CategoryNode({
348
+ node,
349
+ currentSlug,
350
+ depth,
351
+ isLast,
352
+ forceOpen,
353
+ }: {
354
+ node: NavNode;
355
+ currentSlug?: string;
356
+ depth: number;
357
+ isLast: boolean;
358
+ forceOpen: boolean;
359
+ }) {
360
+ const containsCurrent = subtreeContainsSlug(node, currentSlug);
361
+ const isActive = node.slug === currentSlug;
362
+
363
+ // Initial state must match server render (no sessionStorage access)
364
+ // to avoid hydration mismatch. Stored state is restored in useEffect below.
365
+ const [open, setOpen] = useState(containsCurrent ? true : !node.collapsed);
366
+
367
+ // Restore open state from sessionStorage after hydration
368
+ useEffect(() => {
369
+ const stored = getOpenSet();
370
+ if (stored.has(node.slug) && !open) {
371
+ setOpen(true);
372
+ }
373
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
374
+
375
+ // Auto-open category when navigation lands on a descendant
376
+ useEffect(() => {
377
+ if (subtreeContainsSlug(node, currentSlug) && !open) {
378
+ setOpen(true);
379
+ const stored = getOpenSet();
380
+ stored.add(node.slug);
381
+ saveOpenSet(stored);
382
+ }
383
+ }, [currentSlug]); // eslint-disable-line react-hooks/exhaustive-deps
384
+
385
+ // Sync auto-opened state to sessionStorage so it persists across View Transitions
386
+ useEffect(() => {
387
+ if (open) {
388
+ const stored = getOpenSet();
389
+ if (!stored.has(node.slug)) {
390
+ stored.add(node.slug);
391
+ saveOpenSet(stored);
392
+ }
393
+ }
394
+ }, [open, node.slug]);
395
+
396
+ const toggle = useCallback(() => {
397
+ setOpen((prev) => {
398
+ const next = !prev;
399
+ const stored = getOpenSet();
400
+ if (next) {
401
+ stored.add(node.slug);
402
+ } else {
403
+ stored.delete(node.slug);
404
+ }
405
+ saveOpenSet(stored);
406
+ return next;
407
+ });
408
+ }, [node.slug]);
409
+
410
+ const isExpanded = forceOpen || open;
411
+ const paddingLeft = padLeft(depth, true);
412
+
413
+ return (
414
+ <div className={`${depth === 0 ? "border-t border-muted" : ""} ${depth >= 1 && !isLast ? "relative" : ""}`}>
415
+ {depth >= 1 && !isLast && isExpanded && (
416
+ <div
417
+ className="absolute border-l border-solid border-muted z-10"
418
+ style={{
419
+ left: connectorLeft(depth),
420
+ top: 0,
421
+ bottom: 0,
422
+ }}
423
+ />
424
+ )}
425
+ <div className="relative">
426
+ <ConnectorLines depth={depth} isLast={isLast} topPad="calc(0.15rem + var(--spacing-vsp-xs))" />
427
+ {node.href ? (
428
+ <div
429
+ className={`flex w-full items-center text-small font-semibold pt-[0.15rem] ${isActive ? "bg-fg text-bg" : "text-fg"}`}
430
+ >
431
+ <a
432
+ href={node.href}
433
+ aria-current={isActive ? "page" : undefined}
434
+ className={`flex-1 flex items-start gap-hsp-xs py-vsp-xs hover:underline focus:underline break-words ${isActive ? "text-bg" : "text-fg"}`}
435
+ style={{ paddingLeft }}
436
+ >
437
+ {depth === 0 && (
438
+ <span className="flex h-[1lh] items-center">
439
+ <CategoryLinkIcon className={`w-[14px] ${isActive ? "text-bg" : ""}`} />
440
+ </span>
441
+ )}
442
+ <span dangerouslySetInnerHTML={{ __html: smartBreakToHtml(node.label) }} />
443
+ </a>
444
+ <button
445
+ type="button"
446
+ onClick={toggle}
447
+ className={`aspect-square flex items-center justify-center w-[1.5rem] border-y border-l hover:underline focus:underline ${isActive ? "border-bg/30" : "border-muted"}`}
448
+ aria-expanded={isExpanded}
449
+ aria-label={isExpanded ? `Collapse ${node.label}` : `Expand ${node.label}`}
450
+ >
451
+ <ToggleChevron isExpanded={isExpanded} className={isActive ? "text-bg" : "text-muted"} />
452
+ </button>
453
+ </div>
454
+ ) : (
455
+ <button
456
+ type="button"
457
+ onClick={toggle}
458
+ className={`flex w-full items-center gap-hsp-md text-left text-small font-semibold py-vsp-xs text-fg hover:underline focus:underline break-words`}
459
+ style={{ paddingLeft }}
460
+ aria-expanded={isExpanded}
461
+ aria-label={isExpanded ? `Collapse ${node.label}` : `Expand ${node.label}`}
462
+ >
463
+ <span className="aspect-square flex items-center justify-center w-[1.5rem] shrink-0 border border-muted">
464
+ <ToggleChevron isExpanded={isExpanded} className="text-muted" />
465
+ </span>
466
+ <span dangerouslySetInnerHTML={{ __html: smartBreakToHtml(node.label) }} />
467
+ </button>
468
+ )}
469
+ </div>
470
+ {isExpanded && (
471
+ <div>
472
+ <NodeList
473
+ nodes={node.children}
474
+ currentSlug={currentSlug}
475
+ depth={depth + 1}
476
+ forceOpen={forceOpen}
477
+ />
478
+ </div>
479
+ )}
480
+ </div>
481
+ );
482
+ }
483
+
484
+ function LeafNode({
485
+ node,
486
+ currentSlug,
487
+ depth,
488
+ isLast,
489
+ }: {
490
+ node: NavNode;
491
+ currentSlug?: string;
492
+ depth: number;
493
+ isLast: boolean;
494
+ }) {
495
+ if (!node.href) return null;
496
+ const isActive = node.slug === currentSlug;
497
+ const isRoot = depth === 0;
498
+ const paddingLeft = padLeft(depth, isRoot);
499
+
500
+ // For nested last leaves, add visual breathing space as margin on the outer wrapper
501
+ // rather than padding on the anchor — padding would grow the row box and throw off
502
+ // the ConnectorLines geometry (which now uses topPad + 0.5lh of the row to land the
503
+ // horizontal connector at the first-line midpoint).
504
+ const outerClass = isRoot
505
+ ? "border-t border-muted"
506
+ : !isRoot && isLast
507
+ ? "pb-vsp-md"
508
+ : "";
509
+
510
+ const topPad = isRoot
511
+ ? "calc(var(--spacing-vsp-xs) + 0.15rem)"
512
+ : "var(--spacing-vsp-2xs)";
513
+
514
+ return (
515
+ <div className={outerClass}>
516
+ <div className="relative">
517
+ <ConnectorLines depth={depth} isLast={isLast} topPad={topPad} />
518
+ <a
519
+ href={node.href}
520
+ aria-current={isActive ? "page" : undefined}
521
+ className={isRoot
522
+ ? `flex items-start gap-hsp-xs py-[calc(var(--spacing-vsp-xs)+0.15rem)] pr-[4px] text-small font-semibold break-words ${
523
+ isActive ? "bg-fg text-bg" : "text-fg hover:underline focus:underline"
524
+ }`
525
+ : `block py-vsp-2xs pr-[4px] text-small break-words ${
526
+ isActive
527
+ ? "bg-fg font-medium text-bg"
528
+ : "text-muted hover:underline focus:underline"
529
+ }`
530
+ }
531
+ style={{ paddingLeft }}
532
+ >
533
+ {isRoot && (
534
+ <span className="flex h-[1lh] items-center">
535
+ <CategoryLinkIcon className={`w-[14px] ${isActive ? "text-bg" : ""}`} />
536
+ </span>
537
+ )}
538
+ <span dangerouslySetInnerHTML={{ __html: smartBreakToHtml(node.label) }} />
539
+ </a>
540
+ </div>
541
+ </div>
542
+ );
543
+ }