@takazudo/zudo-doc 1.0.2 → 1.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.
@@ -0,0 +1,222 @@
1
+ "use client";
2
+
3
+ /** @jsxRuntime automatic */
4
+ /** @jsxImportSource preact */
5
+ // Use preact hook entrypoints directly — the "react" → "preact/compat" alias
6
+ // lets us consume React-typed components in this Preact app.
7
+ import { useState } from "preact/hooks";
8
+ import type { SidebarNavNode } from "../sidebar/types.js";
9
+ import { INDENT, connectorLeft, ConnectorLines, CategoryLinkIcon } from "../tree-nav-shared/index.js";
10
+ import { ChevronRight } from "../icons/index.js";
11
+
12
+ // site-tree-nav uses wider padding than the narrow sidebar
13
+ const SITE_BASE_PAD = "clamp(0.5rem, 0.8vw, 1rem)";
14
+
15
+ function padLeft(depth: number): string {
16
+ if (depth === 0) return SITE_BASE_PAD;
17
+ return `calc(${depth} * ${INDENT} + 1.25rem + 5px)`;
18
+ }
19
+
20
+ function reorderTree(tree: SidebarNavNode[], order: string[]): SidebarNavNode[] {
21
+ const map = new Map(tree.map((node) => [node.slug, node]));
22
+ const ordered: SidebarNavNode[] = [];
23
+ for (const slug of order) {
24
+ const node = map.get(slug);
25
+ if (node) {
26
+ ordered.push(node);
27
+ map.delete(slug);
28
+ }
29
+ }
30
+ // append unmatched nodes at end
31
+ for (const node of map.values()) {
32
+ ordered.push(node);
33
+ }
34
+ return ordered;
35
+ }
36
+
37
+ export interface SiteTreeNavProps {
38
+ tree: SidebarNavNode[];
39
+ ariaLabel?: string;
40
+ categoryOrder?: string[];
41
+ categoryIgnore?: string[];
42
+ }
43
+
44
+ export function SiteTreeNav({
45
+ tree,
46
+ ariaLabel = "Site index",
47
+ categoryOrder,
48
+ categoryIgnore,
49
+ }: SiteTreeNavProps) {
50
+ let processedTree = tree;
51
+ if (categoryIgnore) {
52
+ const ignoreSet = new Set(categoryIgnore);
53
+ processedTree = processedTree.filter((node) => !ignoreSet.has(node.slug));
54
+ }
55
+ if (categoryOrder) {
56
+ processedTree = reorderTree(processedTree, categoryOrder);
57
+ }
58
+ return (
59
+ <nav
60
+ aria-label={ariaLabel}
61
+ data-site-nav
62
+ className="grid gap-vsp-md"
63
+ style={{
64
+ gridTemplateColumns: "repeat(auto-fill, minmax(min(18rem, 100%), 1fr))",
65
+ }}
66
+ >
67
+ {processedTree.map((node) => (
68
+ <div key={node.slug} className="min-w-0 border border-muted pl-hsp-sm py-vsp-2xs">
69
+ {node.children.length > 0 ? (
70
+ <CategoryNode node={node} depth={0} isLast={true} />
71
+ ) : (
72
+ <LeafNode node={node} depth={0} isLast={true} />
73
+ )}
74
+ </div>
75
+ ))}
76
+ </nav>
77
+ );
78
+ }
79
+ SiteTreeNav.displayName = "SiteTreeNav";
80
+
81
+ function NodeList({ nodes, depth }: { nodes: SidebarNavNode[]; depth: number }) {
82
+ return (
83
+ <>
84
+ {nodes.map((node, index) => {
85
+ const isLast = index === nodes.length - 1;
86
+ return node.children.length > 0 ? (
87
+ <CategoryNode
88
+ key={node.slug}
89
+ node={node}
90
+ depth={depth}
91
+ isLast={isLast}
92
+ />
93
+ ) : (
94
+ <LeafNode
95
+ key={node.slug}
96
+ node={node}
97
+ depth={depth}
98
+ isLast={isLast}
99
+ />
100
+ );
101
+ })}
102
+ </>
103
+ );
104
+ }
105
+
106
+ function CategoryNode({
107
+ node,
108
+ depth,
109
+ isLast,
110
+ }: {
111
+ node: SidebarNavNode;
112
+ depth: number;
113
+ isLast: boolean;
114
+ }) {
115
+ const [open, setOpen] = useState(true);
116
+ const toggle = () => setOpen((prev) => !prev);
117
+ const paddingLeft = padLeft(depth);
118
+
119
+ return (
120
+ <div className={`${depth >= 1 && !isLast ? "relative" : ""}`}>
121
+ {depth >= 1 && !isLast && open && (
122
+ <div
123
+ className="absolute border-l border-dashed border-muted z-local-1"
124
+ style={{
125
+ left: connectorLeft(depth),
126
+ top: 0,
127
+ bottom: 0,
128
+ }}
129
+ />
130
+ )}
131
+ <div className="relative">
132
+ <ConnectorLines
133
+ depth={depth}
134
+ isLast={isLast}
135
+ widthScale={2}
136
+ topPad="calc(0.15rem + var(--spacing-vsp-xs))"
137
+ />
138
+ <div
139
+ className="flex w-full items-center justify-between text-small font-semibold pt-[0.15rem] text-fg"
140
+ style={{ paddingLeft }}
141
+ >
142
+ {node.href ? (
143
+ <a
144
+ href={node.href}
145
+ className="flex-1 flex items-start gap-hsp-xs py-vsp-xs text-fg hover:text-accent hover:underline focus:underline"
146
+ >
147
+ {depth === 0 && (
148
+ <span className="flex h-[1lh] items-center">
149
+ <CategoryLinkIcon className="w-[18px] 2xl:w-[24px]" />
150
+ </span>
151
+ )}
152
+ {node.label}
153
+ </a>
154
+ ) : (
155
+ <button
156
+ type="button"
157
+ onClick={toggle}
158
+ className="flex-1 py-vsp-xs text-left hover:text-accent hover:underline focus:underline"
159
+ >
160
+ {node.label}
161
+ </button>
162
+ )}
163
+ <button
164
+ type="button"
165
+ onClick={toggle}
166
+ className="aspect-square flex items-center justify-center w-[1.75rem] border-y border-l border-muted hover:underline focus:underline"
167
+ aria-expanded={open}
168
+ aria-label={open ? `Collapse ${node.label}` : `Expand ${node.label}`}
169
+ >
170
+ <ChevronRight className={`h-icon-xs w-icon-xs transition-transform duration-150 ${open ? "rotate-90" : ""} text-muted`} />
171
+ </button>
172
+ </div>
173
+ </div>
174
+ {open && (
175
+ <div>
176
+ <NodeList nodes={node.children} depth={depth + 1} />
177
+ </div>
178
+ )}
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function LeafNode({
184
+ node,
185
+ depth,
186
+ isLast,
187
+ }: {
188
+ node: SidebarNavNode;
189
+ depth: number;
190
+ isLast: boolean;
191
+ }) {
192
+ if (!node.href) return null;
193
+ const isRoot = depth === 0;
194
+ const paddingLeft = padLeft(depth);
195
+
196
+ const topPad = isRoot
197
+ ? "calc(var(--spacing-vsp-xs) + 0.15rem)"
198
+ : "var(--spacing-vsp-2xs)";
199
+
200
+ return (
201
+ <div>
202
+ <div className="relative">
203
+ <ConnectorLines depth={depth} isLast={isLast} widthScale={2} topPad={topPad} />
204
+ <a
205
+ href={node.href}
206
+ className={isRoot
207
+ ? "flex items-start gap-hsp-xs py-[calc(var(--spacing-vsp-xs)+0.15rem)] text-small font-semibold text-fg hover:text-accent hover:underline focus:underline"
208
+ : `block py-vsp-2xs ${isLast ? "pb-vsp-xs" : ""} text-small text-fg hover:text-accent hover:underline focus:underline`
209
+ }
210
+ style={{ paddingLeft }}
211
+ >
212
+ {isRoot && (
213
+ <span className="flex h-[1lh] items-center">
214
+ <CategoryLinkIcon className="w-[18px] 2xl:w-[24px]" />
215
+ </span>
216
+ )}
217
+ {node.label}
218
+ </a>
219
+ </div>
220
+ </div>
221
+ );
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "zudo-doc framework primitives layer that sits on top of zfb's engine — sidebar, theme, TOC, breadcrumb, layouts, head injection, View Transitions, SSR-skip wrappers (per ADR-003).",
6
6
  "license": "MIT",
@@ -511,9 +511,9 @@
511
511
  ],
512
512
  "peerDependencies": {
513
513
  "preact": "^10.29.1",
514
- "@takazudo/zfb": "^0.1.0-next.65",
515
- "@takazudo/zfb-runtime": "^0.1.0-next.65",
516
- "@takazudo/zudo-doc-history-server": "^1.0.1",
514
+ "@takazudo/zfb": "^0.1.0-next.67",
515
+ "@takazudo/zfb-runtime": "^0.1.0-next.67",
516
+ "@takazudo/zudo-doc-history-server": "^1.0.2",
517
517
  "@takazudo/zdtp": "^0.3.3",
518
518
  "shiki": "^4.0.2",
519
519
  "zod": "^4.3.6",
@@ -555,8 +555,8 @@
555
555
  "typescript": "^5.0.0",
556
556
  "vitest": "^4.1.0",
557
557
  "zod": "^4.3.6",
558
- "@takazudo/zfb": "0.1.0-next.65",
559
- "@takazudo/zfb-runtime": "0.1.0-next.65"
558
+ "@takazudo/zfb": "0.1.0-next.67",
559
+ "@takazudo/zfb-runtime": "0.1.0-next.67"
560
560
  },
561
561
  "scripts": {
562
562
  "build": "tsup && tsc -p tsconfig.build.json",
@@ -27,7 +27,7 @@
27
27
  // bindings — is A2's job (#2363). With `packageOwnedRoutes` off this module is
28
28
  // never evaluated; it only needs to typecheck and import.
29
29
 
30
- import type { JSX, VNode } from "preact";
30
+ import type { JSX, VNode, ComponentChildren } from "preact";
31
31
  import {
32
32
  settings,
33
33
  defaultLocale,
@@ -92,6 +92,11 @@ import {
92
92
  getThemeDefaultMode as getThemeDefaultModeBase,
93
93
  } from "@takazudo/zudo-doc/nav-data-prep";
94
94
  import { buildSidebarForSection } from "@takazudo/zudo-doc/sidebar-utils";
95
+ import { Details } from "@takazudo/zudo-doc/details";
96
+ import {
97
+ HtmlPreviewWrapper,
98
+ type HtmlPreviewWrapperProps,
99
+ } from "@takazudo/zudo-doc/html-preview-wrapper";
95
100
 
96
101
  // ---------------------------------------------------------------------------
97
102
  // Package-default host-only bindings (see module header).
@@ -371,6 +376,49 @@ const SiteTreeNavWrapper = createSiteTreeNavWrapper({
371
376
  getCategoryOrder,
372
377
  });
373
378
 
379
+ /** HtmlPreview MDX binding (package default).
380
+ *
381
+ * Mirrors the host stub (`pages/_mdx-components.ts`): the global preview
382
+ * config is `settings.htmlPreview`, a SERIALIZABLE setting that DOES ride in
383
+ * the route-context virtual module, so the package can read it directly (no
384
+ * host config needed). `HtmlPreviewWrapper` wraps the bare preview in
385
+ * `<Island when="visible">`, which SSR-renders the preview tree — so an
386
+ * injected docs page using `<HtmlPreview html="…" />` renders correct HTML.
387
+ *
388
+ * Hydration caveat: the interactive code-toggle hydrates only if zfb's islands
389
+ * scanner reaches the `@takazudo/zfb` <Island> import inside
390
+ * `HtmlPreviewWrapper`. The esbuild islands bundler does NOT walk imports of
391
+ * modules whose realpath is under `node_modules` (the same gap the routes
392
+ * plugin stages around — see `plugins/routes.ts`; upstream report:
393
+ * Takazudo/zudo-front-builder, "islands bundler skips addVirtualModule /
394
+ * import-walk for node_modules route realpaths"). SSR output is unaffected. */
395
+ function HtmlPreviewBound(props: HtmlPreviewWrapperProps): JSX.Element {
396
+ return HtmlPreviewWrapper({
397
+ globalConfig: settings.htmlPreview ?? null,
398
+ ...props,
399
+ }) as JSX.Element;
400
+ }
401
+
402
+ /** Island MDX binding (package default) — an SSR pass-through that renders its
403
+ * children, deliberately NOT the real `@takazudo/zfb` <Island>.
404
+ *
405
+ * Why a pass-through and not `Island → @takazudo/zfb` (the sub-issue's literal
406
+ * ask): the MDX corpus never uses `<Island>` as a real render tag (only inside
407
+ * changelog prose), and the host stub itself uses this same pass-through. The
408
+ * real zfb <Island> emits `data-zfb-island="<firstChildTagName>"` for generic
409
+ * MDX children, i.e. a hydration marker pointing at a non-existent manifest
410
+ * entry — and the islands scanner can't register it anyway from a package
411
+ * (node_modules) import (upstream report above). A pass-through renders the
412
+ * server-renderable children correctly with no bogus marker, which is the
413
+ * complete and correct behaviour for the corpus. `when` is ignored at SSR
414
+ * time (it only matters to the client hydration runtime). */
415
+ function IslandPassthrough(props: {
416
+ when?: "load" | "idle" | "visible" | "media";
417
+ children?: ComponentChildren;
418
+ }): ComponentChildren {
419
+ return props.children ?? null;
420
+ }
421
+
374
422
  export function createMdxComponentsBound(lang: string = defaultLocale) {
375
423
  return createMdxComponents({
376
424
  settings,
@@ -380,9 +428,22 @@ export function createMdxComponentsBound(lang: string = defaultLocale) {
380
428
  CategoryTreeNav: CategoryTreeNavWrapper as never,
381
429
  SiteTreeNav: SiteTreeNavWrapper as never,
382
430
  },
383
- // PresetGenerator and other showcase-only slots resolve to a package stub
384
- // (render nothing) they are project-bound and not in the package.
431
+ // Package-owned content components wired here so an INJECTED docs route
432
+ // (packageOwnedRoutes, no host `pages/` stub) renders MDX using these tags
433
+ // without the "MDX requires '<X>' to be passed via the 'components' prop"
434
+ // error (sub-issue #2390 / supersedes #2377).
435
+ // - Details → fully functional (pure <details>, no client JS).
436
+ // - HtmlPreview → SSR-renders; see HtmlPreviewBound for the hydration
437
+ // caveat tied to the node_modules islands-scanner gap.
438
+ // - Island → SSR pass-through; see IslandPassthrough.
439
+ // PresetGenerator stays a package stub (render nothing): it is the
440
+ // showcase's project-bound interactive island and downstream projects stub
441
+ // it. The showcase keeps its host-owned docs catch-all routes (allowlisted)
442
+ // so its real PresetGenerator still renders.
385
443
  extras: {
444
+ Details: Details as never,
445
+ HtmlPreview: HtmlPreviewBound as never,
446
+ Island: IslandPassthrough as never,
386
447
  PresetGenerator: (_props: unknown) => null,
387
448
  },
388
449
  });