@takazudo/zudo-doc 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/doc-body-end-islands/index.d.ts +32 -0
- package/dist/doc-body-end-islands/index.js +40 -0
- package/dist/doc-page-renderer/index.js +5 -2
- package/dist/eject/index.js +24 -0
- package/dist/nav-indexing/doc-card-grid.js +1 -1
- package/dist/nav-indexing/nav-card-grid.js +1 -1
- package/dist/plugins/routes.js +3 -1
- package/dist/preset.d.ts +28 -8
- package/dist/preset.js +37 -9
- package/dist/routes/404.js +2 -2
- package/dist/routes/_chrome.d.ts +9 -9
- package/dist/routes/_chrome.js +53 -12
- package/dist/routes/_context.d.ts +5 -0
- package/dist/routes/_context.js +2 -0
- package/dist/routes/index.js +2 -2
- package/dist/routes/locale-index.js +2 -2
- package/dist/safelist.css +1 -1
- package/dist/settings.d.ts +12 -10
- package/eject/desktop-sidebar-toggle-island/index.tsx +105 -0
- package/eject/doc-history/index.tsx +649 -0
- package/eject/image-enlarge/index.tsx +219 -0
- package/eject/sidebar-toggle-island/index.tsx +165 -0
- package/eject/sidebar-tree-island/index.tsx +589 -0
- package/eject/site-tree-nav-island/index.tsx +222 -0
- package/package.json +10 -6
- package/routes-src/404.tsx +2 -2
- package/routes-src/_chrome.tsx +105 -17
- package/routes-src/_context.ts +5 -0
- package/routes-src/_virtual.d.ts +6 -2
- package/routes-src/index.tsx +2 -2
- package/routes-src/locale-index.tsx +2 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/** @jsxRuntime automatic */
|
|
4
|
+
/** @jsxImportSource preact */
|
|
5
|
+
|
|
6
|
+
// Image-enlarge island — relocated from src/components/image-enlarge.tsx
|
|
7
|
+
// (host showcase) into the package as part of Package-First Wave 3 (S3,
|
|
8
|
+
// epic #2344). Uses shared hook + constants from S1a foundation:
|
|
9
|
+
// - useModalDialog from @takazudo/zudo-doc/use-modal-dialog
|
|
10
|
+
// - IMAGE_ENLARGE_DIALOG_CLASS / ENLARGE_DIALOG_STYLE from @takazudo/zudo-doc/island-types
|
|
11
|
+
//
|
|
12
|
+
// The `/api/ai-chat` endpoint stays host-side (showcase-only).
|
|
13
|
+
// CSS blocks (.zd-enlargeable, .zd-enlarge-btn, .zd-enlarge-dialog*) are
|
|
14
|
+
// shipped in @takazudo/zudo-doc/features.css (moved from src/styles/global.css).
|
|
15
|
+
|
|
16
|
+
import { useState, useEffect } from "preact/compat";
|
|
17
|
+
import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
18
|
+
import { useModalDialog } from "../use-modal-dialog/index.js";
|
|
19
|
+
import {
|
|
20
|
+
IMAGE_ENLARGE_DIALOG_CLASS,
|
|
21
|
+
ENLARGE_DIALOG_STYLE,
|
|
22
|
+
} from "../island-types/index.js";
|
|
23
|
+
|
|
24
|
+
interface ImageData {
|
|
25
|
+
src: string;
|
|
26
|
+
currentSrc: string;
|
|
27
|
+
srcset?: string;
|
|
28
|
+
sizes?: string;
|
|
29
|
+
alt: string;
|
|
30
|
+
naturalWidth: number;
|
|
31
|
+
naturalHeight: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ImageEnlarge() {
|
|
35
|
+
const [imgData, setImgData] = useState<ImageData | null>(null);
|
|
36
|
+
|
|
37
|
+
// Eligibility detection: toggle .zd-enlarge-btn[hidden] per image
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const observedImages = new Set<HTMLImageElement>();
|
|
40
|
+
const sharedResizeObserver = new ResizeObserver((entries) => {
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
evaluateEligibility(entry.target as HTMLImageElement);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
let mutationObserver: MutationObserver | null = null;
|
|
46
|
+
let resizeTimer = 0;
|
|
47
|
+
let loadAbortController = new AbortController();
|
|
48
|
+
|
|
49
|
+
function evaluateEligibility(img: HTMLImageElement) {
|
|
50
|
+
const container = img.closest(".zd-enlargeable");
|
|
51
|
+
if (!container) return;
|
|
52
|
+
const btn = container.querySelector(".zd-enlarge-btn") as HTMLElement | null;
|
|
53
|
+
if (!btn) return;
|
|
54
|
+
const eligible = img.naturalWidth > img.clientWidth * window.devicePixelRatio;
|
|
55
|
+
if (eligible) {
|
|
56
|
+
btn.removeAttribute("hidden");
|
|
57
|
+
} else {
|
|
58
|
+
btn.setAttribute("hidden", "");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function observeImage(img: HTMLImageElement) {
|
|
63
|
+
if (observedImages.has(img)) return;
|
|
64
|
+
observedImages.add(img);
|
|
65
|
+
sharedResizeObserver.observe(img);
|
|
66
|
+
if (img.complete) {
|
|
67
|
+
evaluateEligibility(img);
|
|
68
|
+
} else {
|
|
69
|
+
img.addEventListener(
|
|
70
|
+
"load",
|
|
71
|
+
() => evaluateEligibility(img),
|
|
72
|
+
{ once: true, signal: loadAbortController.signal },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scanContent() {
|
|
78
|
+
const scope = document.querySelector("main .zd-content");
|
|
79
|
+
if (!scope) return;
|
|
80
|
+
scope.querySelectorAll<HTMLImageElement>(".zd-enlargeable img").forEach(observeImage);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function startObserving() {
|
|
84
|
+
const scope = document.querySelector("main .zd-content");
|
|
85
|
+
if (scope) {
|
|
86
|
+
mutationObserver = new MutationObserver((mutations) => {
|
|
87
|
+
for (const mutation of mutations) {
|
|
88
|
+
for (const node of mutation.addedNodes) {
|
|
89
|
+
if (!(node instanceof Element)) continue;
|
|
90
|
+
if (node.matches(".zd-enlargeable")) {
|
|
91
|
+
node.querySelectorAll<HTMLImageElement>("img").forEach(observeImage);
|
|
92
|
+
}
|
|
93
|
+
node.querySelectorAll<HTMLImageElement>(".zd-enlargeable img").forEach(observeImage);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
mutationObserver.observe(scope, { childList: true, subtree: true });
|
|
98
|
+
}
|
|
99
|
+
scanContent();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleWindowResize() {
|
|
103
|
+
clearTimeout(resizeTimer);
|
|
104
|
+
resizeTimer = window.setTimeout(() => {
|
|
105
|
+
observedImages.forEach((img) => evaluateEligibility(img));
|
|
106
|
+
}, 150);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleAfterSwap() {
|
|
110
|
+
sharedResizeObserver.disconnect();
|
|
111
|
+
observedImages.clear();
|
|
112
|
+
mutationObserver?.disconnect();
|
|
113
|
+
mutationObserver = null;
|
|
114
|
+
loadAbortController.abort();
|
|
115
|
+
loadAbortController = new AbortController();
|
|
116
|
+
startObserving();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
startObserving();
|
|
120
|
+
window.addEventListener("resize", handleWindowResize);
|
|
121
|
+
document.addEventListener(AFTER_NAVIGATE_EVENT, handleAfterSwap);
|
|
122
|
+
|
|
123
|
+
return () => {
|
|
124
|
+
sharedResizeObserver.disconnect();
|
|
125
|
+
observedImages.clear();
|
|
126
|
+
mutationObserver?.disconnect();
|
|
127
|
+
loadAbortController.abort();
|
|
128
|
+
window.removeEventListener("resize", handleWindowResize);
|
|
129
|
+
document.removeEventListener(AFTER_NAVIGATE_EVENT, handleAfterSwap);
|
|
130
|
+
clearTimeout(resizeTimer);
|
|
131
|
+
};
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
function handleDocumentClick(e: MouseEvent) {
|
|
136
|
+
const sel = window.getSelection();
|
|
137
|
+
if (sel && !sel.isCollapsed) return;
|
|
138
|
+
const target = e.target as Element;
|
|
139
|
+
const container = target.closest(".zd-enlargeable");
|
|
140
|
+
if (!container) return;
|
|
141
|
+
if (!target.closest(".zd-enlarge-btn") && !target.closest("img")) return;
|
|
142
|
+
const btn = container.querySelector(".zd-enlarge-btn") as HTMLElement | null;
|
|
143
|
+
if (!btn || btn.hasAttribute("hidden")) return;
|
|
144
|
+
const img = container.querySelector("img") as HTMLImageElement | null;
|
|
145
|
+
if (!img) return;
|
|
146
|
+
setImgData({
|
|
147
|
+
src: img.src,
|
|
148
|
+
currentSrc: img.currentSrc,
|
|
149
|
+
srcset: img.srcset || undefined,
|
|
150
|
+
sizes: img.sizes || undefined,
|
|
151
|
+
alt: img.alt,
|
|
152
|
+
naturalWidth: img.naturalWidth,
|
|
153
|
+
naturalHeight: img.naturalHeight,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
document.addEventListener("click", handleDocumentClick);
|
|
157
|
+
return () => document.removeEventListener("click", handleDocumentClick);
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
const handleClose = () => setImgData(null);
|
|
161
|
+
|
|
162
|
+
const { dialogRef, handleBackdropClick } = useModalDialog({
|
|
163
|
+
isOpen: imgData !== null,
|
|
164
|
+
onClose: handleClose,
|
|
165
|
+
navigateEvent: AFTER_NAVIGATE_EVENT,
|
|
166
|
+
backdropClickClose: true,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<dialog
|
|
171
|
+
ref={dialogRef}
|
|
172
|
+
onClick={handleBackdropClick}
|
|
173
|
+
className={IMAGE_ENLARGE_DIALOG_CLASS}
|
|
174
|
+
style={ENLARGE_DIALOG_STYLE}
|
|
175
|
+
>
|
|
176
|
+
{imgData && (
|
|
177
|
+
<>
|
|
178
|
+
<div className="relative">
|
|
179
|
+
<img
|
|
180
|
+
src={imgData.currentSrc || imgData.src}
|
|
181
|
+
srcSet={imgData.srcset}
|
|
182
|
+
sizes={imgData.srcset ? "85vw" : undefined}
|
|
183
|
+
alt={imgData.alt}
|
|
184
|
+
className="block max-h-[85vh] max-w-[85vw] object-contain"
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => dialogRef.current?.close()}
|
|
190
|
+
className="zd-enlarge-dialog-close"
|
|
191
|
+
aria-label="Close enlarged image"
|
|
192
|
+
>
|
|
193
|
+
<svg viewBox="0 0 161.03 161.03" fill="currentColor" aria-hidden="true" focusable="false">
|
|
194
|
+
<polygon points="161.03 10.27 150.76 0 80.51 70.24 10.27 0 0 10.27 70.24 80.51 0 150.76 10.27 161.03 80.51 90.78 150.76 161.03 161.03 150.76 90.78 80.51 161.03 10.27" />
|
|
195
|
+
</svg>
|
|
196
|
+
</button>
|
|
197
|
+
</>
|
|
198
|
+
)}
|
|
199
|
+
</dialog>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
ImageEnlarge.displayName = "ImageEnlarge";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Static SSR fallback for the {@link ImageEnlarge} island.
|
|
206
|
+
*
|
|
207
|
+
* Renders an empty, closed `<dialog class="zd-enlarge-dialog ...">` so the
|
|
208
|
+
* dist HTML carries the dialog shell even before hydration. Sources its
|
|
209
|
+
* class and inline style from the shared constants in island-types so the
|
|
210
|
+
* SSR fallback cannot drift from the hydrated dialog above.
|
|
211
|
+
*/
|
|
212
|
+
export function ImageEnlargeSsrFallback() {
|
|
213
|
+
return (
|
|
214
|
+
<dialog
|
|
215
|
+
className={IMAGE_ENLARGE_DIALOG_CLASS}
|
|
216
|
+
style={ENLARGE_DIALOG_STYLE}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
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, useEffect } from "preact/hooks";
|
|
8
|
+
// After zudolab/zudo-doc#1335 the host components pull lifecycle event names
|
|
9
|
+
// from the v2 transitions module rather than hard-coding `astro:*` literals.
|
|
10
|
+
import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
11
|
+
import { SidebarTree } from "../sidebar-tree-island/index.js";
|
|
12
|
+
import type { SidebarNavNode, SidebarRootMenuItem, SidebarLocaleLink } from "../sidebar/types.js";
|
|
13
|
+
|
|
14
|
+
const cx = (...classes: Array<string | false | null | undefined>) =>
|
|
15
|
+
classes.filter(Boolean).join(" ");
|
|
16
|
+
|
|
17
|
+
// Mobile drawer hosts the SidebarTree directly (rather than receiving it as
|
|
18
|
+
// JSX children) so the tree's data props ride across the SSR → hydrate
|
|
19
|
+
// boundary inside the Island marker's `data-props` attribute. zfb's
|
|
20
|
+
// `Island()` only serialises a child component's *own* props (excluding
|
|
21
|
+
// `children`); when SidebarTree was passed as `children`, its data was
|
|
22
|
+
// dropped during hydration and Preact wiped the SSR-rendered tree DOM.
|
|
23
|
+
// Mirroring the desktop `<Sidebar treeComponent={SidebarTree} ...>` shape
|
|
24
|
+
// keeps the data attached to the wrapping island. zudolab/zudo-doc#1355
|
|
25
|
+
// wave 13.5.
|
|
26
|
+
|
|
27
|
+
export interface SidebarToggleProps {
|
|
28
|
+
nodes: SidebarNavNode[];
|
|
29
|
+
currentSlug?: string;
|
|
30
|
+
rootMenuItems?: SidebarRootMenuItem[];
|
|
31
|
+
backToMenuLabel?: string;
|
|
32
|
+
localeLinks?: SidebarLocaleLink[];
|
|
33
|
+
themeDefaultMode?: "light" | "dark";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SidebarToggle({
|
|
37
|
+
nodes,
|
|
38
|
+
currentSlug,
|
|
39
|
+
rootMenuItems,
|
|
40
|
+
backToMenuLabel,
|
|
41
|
+
localeLinks,
|
|
42
|
+
themeDefaultMode,
|
|
43
|
+
}: SidebarToggleProps) {
|
|
44
|
+
// Initial state must match SSR (`open=false`) so the hydration DOM
|
|
45
|
+
// matches the SSG output byte-for-byte. The backdrop and toggle-icon
|
|
46
|
+
// are rendered unconditionally below so the hydration tree has the
|
|
47
|
+
// same shape regardless of `open`, preventing Preact from re-mounting
|
|
48
|
+
// the subtree (which can drop click handlers on the hamburger button).
|
|
49
|
+
const [open, setOpen] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (open) {
|
|
53
|
+
document.body.style.overflow = "hidden";
|
|
54
|
+
} else {
|
|
55
|
+
document.body.style.overflow = "";
|
|
56
|
+
}
|
|
57
|
+
return () => {
|
|
58
|
+
document.body.style.overflow = "";
|
|
59
|
+
};
|
|
60
|
+
}, [open]);
|
|
61
|
+
|
|
62
|
+
// Close mobile sidebar on View Transition navigation
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
function handleSwap() {
|
|
65
|
+
setOpen(false);
|
|
66
|
+
}
|
|
67
|
+
document.addEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
|
|
68
|
+
return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
{/* Hamburger button - visible only on mobile.
|
|
74
|
+
Both icons are always rendered so the SSR output has the same
|
|
75
|
+
DOM shape as the post-hydration tree. The closed-state icon is
|
|
76
|
+
hidden via `hidden` when open=true, and vice versa, so Preact's
|
|
77
|
+
hydration walk sees byte-stable markup and keeps the click
|
|
78
|
+
handler attached. */}
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => setOpen(!open)}
|
|
82
|
+
className="lg:hidden px-hsp-sm py-vsp-xs -ml-hsp-sm mr-hsp-sm text-muted hover:text-fg"
|
|
83
|
+
aria-label={open ? "Close sidebar" : "Open sidebar"}
|
|
84
|
+
aria-expanded={open}
|
|
85
|
+
>
|
|
86
|
+
{/* X icon — visible only when open */}
|
|
87
|
+
<svg
|
|
88
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
89
|
+
className={cx("h-icon-lg w-icon-lg", !open && "hidden")}
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
fill="none"
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
stroke="currentColor"
|
|
94
|
+
strokeWidth={2}
|
|
95
|
+
>
|
|
96
|
+
<path
|
|
97
|
+
strokeLinecap="round"
|
|
98
|
+
strokeLinejoin="round"
|
|
99
|
+
d="M6 18L18 6M6 6l12 12"
|
|
100
|
+
/>
|
|
101
|
+
</svg>
|
|
102
|
+
{/* Hamburger icon — visible only when closed */}
|
|
103
|
+
<svg
|
|
104
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
105
|
+
className={cx("h-icon-lg w-icon-lg", open && "hidden")}
|
|
106
|
+
aria-hidden="true"
|
|
107
|
+
fill="none"
|
|
108
|
+
viewBox="0 0 24 24"
|
|
109
|
+
stroke="currentColor"
|
|
110
|
+
strokeWidth={2}
|
|
111
|
+
>
|
|
112
|
+
<path
|
|
113
|
+
strokeLinecap="round"
|
|
114
|
+
strokeLinejoin="round"
|
|
115
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
116
|
+
/>
|
|
117
|
+
</svg>
|
|
118
|
+
</button>
|
|
119
|
+
|
|
120
|
+
{/* Backdrop overlay - mobile only.
|
|
121
|
+
Rendered unconditionally; CSS `hidden` toggles visibility so
|
|
122
|
+
the SSR DOM tree matches the hydrated tree (no subtree
|
|
123
|
+
mount/unmount across the hydration boundary).
|
|
124
|
+
`z-modal-backdrop` (50) intentionally sits ABOVE the header
|
|
125
|
+
(`z-toolbar`, 20): the open mobile drawer is a modal surface that
|
|
126
|
+
dims the whole viewport, header included. Closing is via tapping the
|
|
127
|
+
backdrop (onClick below), so the header hamburger being dimmed under
|
|
128
|
+
it is fine. */}
|
|
129
|
+
<div
|
|
130
|
+
className={cx("fixed inset-0 z-modal-backdrop bg-overlay/30 lg:hidden", !open && "hidden")}
|
|
131
|
+
aria-hidden={!open}
|
|
132
|
+
onClick={() => setOpen(false)}
|
|
133
|
+
/>
|
|
134
|
+
|
|
135
|
+
{/* Sidebar panel - mobile only (desktop sidebar is in doc-layout).
|
|
136
|
+
`inert` when closed: the panel is only visually hidden via
|
|
137
|
+
`-translate-x-full`, so without `inert` its links/filter/buttons
|
|
138
|
+
stay in the tab order and accessibility tree while off-screen
|
|
139
|
+
(zudolab/zudo-doc#2059). `inert={false}` serialises to no attribute,
|
|
140
|
+
so the SSR (open=false → `inert`) and initial client render stay
|
|
141
|
+
byte-stable for hydration. */}
|
|
142
|
+
<aside
|
|
143
|
+
inert={!open}
|
|
144
|
+
className={`
|
|
145
|
+
fixed top-[3.5rem] left-0 z-modal h-[calc(100vh-3.5rem)] w-[16rem] flex flex-col
|
|
146
|
+
border-r border-muted bg-bg transition-transform duration-200
|
|
147
|
+
lg:hidden
|
|
148
|
+
${open ? "translate-x-0" : "-translate-x-full"}
|
|
149
|
+
`}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex-1 overflow-y-auto">
|
|
152
|
+
<SidebarTree
|
|
153
|
+
nodes={nodes}
|
|
154
|
+
currentSlug={currentSlug}
|
|
155
|
+
rootMenuItems={rootMenuItems}
|
|
156
|
+
backToMenuLabel={backToMenuLabel}
|
|
157
|
+
localeLinks={localeLinks}
|
|
158
|
+
themeDefaultMode={themeDefaultMode}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</aside>
|
|
162
|
+
</>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
SidebarToggle.displayName = "SidebarToggle";
|