@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,649 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/** @jsxRuntime automatic */
|
|
4
|
+
/** @jsxImportSource preact */
|
|
5
|
+
|
|
6
|
+
// DocHistory island — relocated from src/components/doc-history.tsx (epic #2344, S4).
|
|
7
|
+
// Uses the shared hook and types shipped by S1a instead of re-inlining them:
|
|
8
|
+
// - useModalDialog from @takazudo/zudo-doc/use-modal-dialog (open/close sync, focus management)
|
|
9
|
+
// - DocHistoryData / DocHistoryEntry from @takazudo/zudo-doc/island-types
|
|
10
|
+
// - SmartBreak from @takazudo/zudo-doc/smart-break
|
|
11
|
+
//
|
|
12
|
+
// CSS: island-coupled .diff-* rules are now in packages/zudo-doc/src/features.css
|
|
13
|
+
// (moved from src/styles/global.css in this same commit).
|
|
14
|
+
|
|
15
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from "preact/compat";
|
|
16
|
+
import type { DocHistoryData, DocHistoryEntry } from "../island-types/index.js";
|
|
17
|
+
import { SmartBreak } from "../smart-break/index.js";
|
|
18
|
+
import { History, Close, ArrowLeft } from "../icons/index.js";
|
|
19
|
+
import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
20
|
+
import { useModalDialog } from "../use-modal-dialog/index.js";
|
|
21
|
+
|
|
22
|
+
interface DocHistoryProps {
|
|
23
|
+
slug: string;
|
|
24
|
+
locale?: string;
|
|
25
|
+
basePath?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type PanelView = "closed" | "revisions" | "diff";
|
|
29
|
+
|
|
30
|
+
interface DiffSelection {
|
|
31
|
+
older: DocHistoryEntry;
|
|
32
|
+
newer: DocHistoryEntry;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ────────────────────────────────────────────
|
|
36
|
+
* Spinner (matches page-loading-overlay style)
|
|
37
|
+
* ──────────────────────────────────────────── */
|
|
38
|
+
|
|
39
|
+
function Spinner() {
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex items-center justify-center py-vsp-xl">
|
|
42
|
+
<span
|
|
43
|
+
className="inline-block box-border rounded-full animate-spin"
|
|
44
|
+
style={{
|
|
45
|
+
width: 48,
|
|
46
|
+
height: 48,
|
|
47
|
+
border: "5px solid var(--color-fg, #fff)",
|
|
48
|
+
borderBottomColor: "transparent",
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ────────────────────────────────────────────
|
|
56
|
+
* Side-by-side diff row types and builder
|
|
57
|
+
* ──────────────────────────────────────────── */
|
|
58
|
+
|
|
59
|
+
interface DiffRow {
|
|
60
|
+
leftLine: string | null; // null = empty (added-only row)
|
|
61
|
+
rightLine: string | null; // null = empty (removed-only row)
|
|
62
|
+
leftNum: number | null;
|
|
63
|
+
rightNum: number | null;
|
|
64
|
+
type: "context" | "removed" | "added" | "changed";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildSideBySideRows(
|
|
68
|
+
changes: DiffChanges,
|
|
69
|
+
): DiffRow[] {
|
|
70
|
+
const rows: DiffRow[] = [];
|
|
71
|
+
let leftNum = 0;
|
|
72
|
+
let rightNum = 0;
|
|
73
|
+
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < changes.length) {
|
|
76
|
+
const change = changes[i];
|
|
77
|
+
if (!change) { i++; continue; }
|
|
78
|
+
|
|
79
|
+
if (!change.added && !change.removed) {
|
|
80
|
+
// Context lines — show on both sides
|
|
81
|
+
const lines = change.value.replace(/\n$/, "").split("\n");
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
leftNum++;
|
|
84
|
+
rightNum++;
|
|
85
|
+
rows.push({ leftLine: line, rightLine: line, leftNum, rightNum, type: "context" });
|
|
86
|
+
}
|
|
87
|
+
i++;
|
|
88
|
+
} else if (change.removed && i + 1 < changes.length) {
|
|
89
|
+
const nextChange = changes[i + 1];
|
|
90
|
+
if (nextChange?.added) {
|
|
91
|
+
// Paired remove+add — show side by side
|
|
92
|
+
const removedLines = change.value.replace(/\n$/, "").split("\n");
|
|
93
|
+
const addedLines = nextChange.value.replace(/\n$/, "").split("\n");
|
|
94
|
+
const maxLen = Math.max(removedLines.length, addedLines.length);
|
|
95
|
+
for (let j = 0; j < maxLen; j++) {
|
|
96
|
+
const left = j < removedLines.length ? (removedLines[j] ?? null) : null;
|
|
97
|
+
const right = j < addedLines.length ? (addedLines[j] ?? null) : null;
|
|
98
|
+
if (left !== null) leftNum++;
|
|
99
|
+
if (right !== null) rightNum++;
|
|
100
|
+
rows.push({
|
|
101
|
+
leftLine: left,
|
|
102
|
+
rightLine: right,
|
|
103
|
+
leftNum: left !== null ? leftNum : null,
|
|
104
|
+
rightNum: right !== null ? rightNum : null,
|
|
105
|
+
type: "changed",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
i += 2;
|
|
109
|
+
} else {
|
|
110
|
+
const lines = change.value.replace(/\n$/, "").split("\n");
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
leftNum++;
|
|
113
|
+
rows.push({ leftLine: line, rightLine: null, leftNum, rightNum: null, type: "removed" });
|
|
114
|
+
}
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
} else if (change.removed) {
|
|
118
|
+
const lines = change.value.replace(/\n$/, "").split("\n");
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
leftNum++;
|
|
121
|
+
rows.push({ leftLine: line, rightLine: null, leftNum, rightNum: null, type: "removed" });
|
|
122
|
+
}
|
|
123
|
+
i++;
|
|
124
|
+
} else {
|
|
125
|
+
// added
|
|
126
|
+
const lines = change.value.replace(/\n$/, "").split("\n");
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
rightNum++;
|
|
129
|
+
rows.push({ leftLine: null, rightLine: line, leftNum: null, rightNum, type: "added" });
|
|
130
|
+
}
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return rows;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ────────────────────────────────────────────
|
|
139
|
+
* DiffViewer sub-component (side-by-side)
|
|
140
|
+
* ──────────────────────────────────────────── */
|
|
141
|
+
|
|
142
|
+
// Hashes — not full file content — are the cache key so the keys stay
|
|
143
|
+
// short regardless of doc size. Map insertion order is the LRU.
|
|
144
|
+
// The diff module is lazy-imported (only loaded when the user opens the Compare
|
|
145
|
+
// view) to avoid eagerly bundling it into the per-page islands chunk.
|
|
146
|
+
import type { Change } from "diff";
|
|
147
|
+
type DiffChanges = Change[];
|
|
148
|
+
const DIFF_CACHE_LIMIT = 32;
|
|
149
|
+
const diffCache = new Map<string, DiffChanges>();
|
|
150
|
+
|
|
151
|
+
async function getCachedDiff(
|
|
152
|
+
olderHash: string,
|
|
153
|
+
newerHash: string,
|
|
154
|
+
olderContent: string,
|
|
155
|
+
newerContent: string,
|
|
156
|
+
): Promise<DiffChanges> {
|
|
157
|
+
const key = `${olderHash}::${newerHash}`;
|
|
158
|
+
const hit = diffCache.get(key);
|
|
159
|
+
if (hit) {
|
|
160
|
+
// Refresh recency by re-inserting at the end of the iteration order.
|
|
161
|
+
diffCache.delete(key);
|
|
162
|
+
diffCache.set(key, hit);
|
|
163
|
+
return hit;
|
|
164
|
+
}
|
|
165
|
+
// Lazy-load diff — only needed after History → Compare. This keeps the
|
|
166
|
+
// module out of the eager islands bundle.
|
|
167
|
+
const { diffLines } = await import("diff");
|
|
168
|
+
const changes = diffLines(olderContent, newerContent);
|
|
169
|
+
diffCache.set(key, changes);
|
|
170
|
+
if (diffCache.size > DIFF_CACHE_LIMIT) {
|
|
171
|
+
const oldest = diffCache.keys().next().value;
|
|
172
|
+
if (oldest !== undefined) diffCache.delete(oldest);
|
|
173
|
+
}
|
|
174
|
+
return changes;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function DiffViewer({
|
|
178
|
+
selection,
|
|
179
|
+
onBack,
|
|
180
|
+
showBackButton,
|
|
181
|
+
}: {
|
|
182
|
+
selection: DiffSelection;
|
|
183
|
+
onBack: () => void;
|
|
184
|
+
showBackButton: boolean;
|
|
185
|
+
}) {
|
|
186
|
+
const [changes, setChanges] = useState<DiffChanges | null>(null);
|
|
187
|
+
const [diffError, setDiffError] = useState<string | null>(null);
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
let cancelled = false;
|
|
191
|
+
setDiffError(null);
|
|
192
|
+
getCachedDiff(
|
|
193
|
+
selection.older.hash,
|
|
194
|
+
selection.newer.hash,
|
|
195
|
+
selection.older.content,
|
|
196
|
+
selection.newer.content,
|
|
197
|
+
).then((result) => {
|
|
198
|
+
if (!cancelled) setChanges(result);
|
|
199
|
+
}).catch((e: unknown) => {
|
|
200
|
+
if (!cancelled) {
|
|
201
|
+
setDiffError(e instanceof Error ? e.message : "Failed to compute diff");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
return () => { cancelled = true; };
|
|
205
|
+
}, [selection.older.hash, selection.newer.hash]);
|
|
206
|
+
|
|
207
|
+
const rows = useMemo(
|
|
208
|
+
() => (changes ? buildSideBySideRows(changes) : []),
|
|
209
|
+
[changes],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div className="flex flex-col h-full">
|
|
214
|
+
{/* Header */}
|
|
215
|
+
<div className="flex items-center gap-hsp-sm px-hsp-lg py-vsp-xs border-b border-muted">
|
|
216
|
+
{showBackButton && (
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={onBack}
|
|
220
|
+
className="text-muted hover:text-fg lg:hidden"
|
|
221
|
+
aria-label="Back to revisions"
|
|
222
|
+
>
|
|
223
|
+
<ArrowLeft className="h-icon-sm w-icon-sm" />
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
<div className="flex-1 min-w-0 flex">
|
|
227
|
+
<div className="w-1/2 text-small text-muted font-mono truncate pr-hsp-sm">
|
|
228
|
+
{selection.older.hash.slice(0, 7)}
|
|
229
|
+
</div>
|
|
230
|
+
<div className="w-1/2 text-small text-muted font-mono truncate pl-hsp-sm">
|
|
231
|
+
{selection.newer.hash.slice(0, 7)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* Side-by-side diff — shows a spinner while the diff module lazy-loads */}
|
|
237
|
+
{diffError && (
|
|
238
|
+
<div className="px-hsp-lg py-vsp-lg text-danger text-small">{diffError}</div>
|
|
239
|
+
)}
|
|
240
|
+
{!changes && !diffError && <Spinner />}
|
|
241
|
+
<div className={`flex-1 overflow-auto${!changes ? " hidden" : ""}`}>
|
|
242
|
+
<table className="w-full border-collapse" style={{ tableLayout: "fixed" }}>
|
|
243
|
+
<colgroup>
|
|
244
|
+
<col style={{ width: "2.5rem" }} />
|
|
245
|
+
<col />
|
|
246
|
+
<col style={{ width: "2.5rem" }} />
|
|
247
|
+
<col />
|
|
248
|
+
</colgroup>
|
|
249
|
+
<tbody>
|
|
250
|
+
{rows.map((row, idx) => {
|
|
251
|
+
const leftBg =
|
|
252
|
+
row.type === "removed" || row.type === "changed"
|
|
253
|
+
? "diff-line-removed"
|
|
254
|
+
: "";
|
|
255
|
+
const rightBg =
|
|
256
|
+
row.type === "added" || row.type === "changed"
|
|
257
|
+
? "diff-line-added"
|
|
258
|
+
: "";
|
|
259
|
+
const leftEmpty = row.leftLine === null;
|
|
260
|
+
const rightEmpty = row.rightLine === null;
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<tr key={idx} className="diff-row">
|
|
264
|
+
{/* Left line number */}
|
|
265
|
+
<td className={`diff-line-num ${leftBg}`}>
|
|
266
|
+
{row.leftNum ?? ""}
|
|
267
|
+
</td>
|
|
268
|
+
{/* Left content */}
|
|
269
|
+
<td className={`diff-line-content ${leftBg}${leftEmpty ? " diff-line-empty" : ""}`}>
|
|
270
|
+
{row.leftLine ?? ""}
|
|
271
|
+
</td>
|
|
272
|
+
{/* Right line number */}
|
|
273
|
+
<td className={`diff-line-num ${rightBg}`}>
|
|
274
|
+
{row.rightNum ?? ""}
|
|
275
|
+
</td>
|
|
276
|
+
{/* Right content */}
|
|
277
|
+
<td className={`diff-line-content ${rightBg}${rightEmpty ? " diff-line-empty" : ""}`}>
|
|
278
|
+
{row.rightLine ?? ""}
|
|
279
|
+
</td>
|
|
280
|
+
</tr>
|
|
281
|
+
);
|
|
282
|
+
})}
|
|
283
|
+
</tbody>
|
|
284
|
+
</table>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ────────────────────────────────────────────
|
|
291
|
+
* RevisionList sub-component
|
|
292
|
+
* ──────────────────────────────────────────── */
|
|
293
|
+
|
|
294
|
+
function RevisionList({
|
|
295
|
+
entries,
|
|
296
|
+
onSelectDiff,
|
|
297
|
+
}: {
|
|
298
|
+
entries: DocHistoryEntry[];
|
|
299
|
+
onSelectDiff: (selection: DiffSelection) => void;
|
|
300
|
+
}) {
|
|
301
|
+
const [selectedA, setSelectedA] = useState<number>(1); // older (default: second entry)
|
|
302
|
+
const [selectedB, setSelectedB] = useState<number>(0); // newer (default: first entry)
|
|
303
|
+
|
|
304
|
+
if (entries.length === 0) {
|
|
305
|
+
return (
|
|
306
|
+
<div className="px-hsp-lg py-vsp-lg text-muted text-small">
|
|
307
|
+
No revision history available.
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const canCompare =
|
|
313
|
+
selectedA !== selectedB &&
|
|
314
|
+
selectedA >= 0 &&
|
|
315
|
+
selectedB >= 0 &&
|
|
316
|
+
selectedA < entries.length &&
|
|
317
|
+
selectedB < entries.length;
|
|
318
|
+
|
|
319
|
+
function handleCompare() {
|
|
320
|
+
if (!canCompare) return;
|
|
321
|
+
const idxOlder = Math.max(selectedA, selectedB);
|
|
322
|
+
const idxNewer = Math.min(selectedA, selectedB);
|
|
323
|
+
const olderEntry = entries[idxOlder];
|
|
324
|
+
const newerEntry = entries[idxNewer];
|
|
325
|
+
if (!olderEntry || !newerEntry) return;
|
|
326
|
+
onSelectDiff({
|
|
327
|
+
older: olderEntry,
|
|
328
|
+
newer: newerEntry,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div className="flex flex-col h-full">
|
|
334
|
+
{/* Compare bar */}
|
|
335
|
+
{entries.length >= 2 && (
|
|
336
|
+
<div className="px-hsp-lg py-vsp-xs border-b border-muted flex items-center gap-hsp-sm">
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
disabled={!canCompare}
|
|
340
|
+
onClick={handleCompare}
|
|
341
|
+
className={
|
|
342
|
+
canCompare
|
|
343
|
+
? "px-hsp-md py-vsp-2xs text-small rounded bg-accent text-bg hover:bg-accent-hover"
|
|
344
|
+
: "px-hsp-md py-vsp-2xs text-small rounded bg-surface text-muted cursor-not-allowed"
|
|
345
|
+
}
|
|
346
|
+
>
|
|
347
|
+
Compare
|
|
348
|
+
</button>
|
|
349
|
+
<span className="text-caption text-muted">
|
|
350
|
+
Select two revisions (A / B)
|
|
351
|
+
</span>
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
|
|
355
|
+
{/* Revision entries */}
|
|
356
|
+
<div className="flex-1 overflow-auto">
|
|
357
|
+
{entries.map((entry, idx) => {
|
|
358
|
+
const isA = selectedA === idx;
|
|
359
|
+
const isB = selectedB === idx;
|
|
360
|
+
const dateStr = formatDate(entry.date);
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div
|
|
364
|
+
key={entry.hash}
|
|
365
|
+
className={
|
|
366
|
+
isA || isB
|
|
367
|
+
? "px-hsp-lg py-vsp-xs border-b border-muted bg-surface"
|
|
368
|
+
: "px-hsp-lg py-vsp-xs border-b border-muted hover:bg-surface"
|
|
369
|
+
}
|
|
370
|
+
>
|
|
371
|
+
<div className="flex items-start gap-hsp-sm">
|
|
372
|
+
{/* Selection badges */}
|
|
373
|
+
{entries.length >= 2 && (
|
|
374
|
+
<div className="flex flex-col gap-vsp-2xs pt-[2px] shrink-0">
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={() => setSelectedA(idx)}
|
|
378
|
+
className={
|
|
379
|
+
isA
|
|
380
|
+
? "w-[1.5rem] h-[1.25rem] text-caption rounded flex items-center justify-center bg-accent text-bg"
|
|
381
|
+
: "w-[1.5rem] h-[1.25rem] text-caption rounded flex items-center justify-center border border-muted text-muted hover:border-fg hover:text-fg"
|
|
382
|
+
}
|
|
383
|
+
aria-label={`Select revision ${entry.hash.slice(0, 7)} as A`}
|
|
384
|
+
>
|
|
385
|
+
A
|
|
386
|
+
</button>
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
onClick={() => setSelectedB(idx)}
|
|
390
|
+
className={
|
|
391
|
+
isB
|
|
392
|
+
? "w-[1.5rem] h-[1.25rem] text-caption rounded flex items-center justify-center bg-accent text-bg"
|
|
393
|
+
: "w-[1.5rem] h-[1.25rem] text-caption rounded flex items-center justify-center border border-muted text-muted hover:border-fg hover:text-fg"
|
|
394
|
+
}
|
|
395
|
+
aria-label={`Select revision ${entry.hash.slice(0, 7)} as B`}
|
|
396
|
+
>
|
|
397
|
+
B
|
|
398
|
+
</button>
|
|
399
|
+
</div>
|
|
400
|
+
)}
|
|
401
|
+
|
|
402
|
+
{/* Revision info */}
|
|
403
|
+
<div className="min-w-0 flex-1">
|
|
404
|
+
<div className="flex items-baseline gap-hsp-sm">
|
|
405
|
+
<code className="text-caption text-accent font-mono">
|
|
406
|
+
{entry.hash.slice(0, 7)}
|
|
407
|
+
</code>
|
|
408
|
+
<span className="text-caption text-muted">{dateStr}</span>
|
|
409
|
+
</div>
|
|
410
|
+
<div className="text-small text-fg mt-vsp-2xs truncate">
|
|
411
|
+
<SmartBreak>{entry.message}</SmartBreak>
|
|
412
|
+
</div>
|
|
413
|
+
<div className="text-caption text-muted">{entry.author}</div>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
})}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ────────────────────────────────────────────
|
|
425
|
+
* Date formatter
|
|
426
|
+
* ──────────────────────────────────────────── */
|
|
427
|
+
|
|
428
|
+
function formatDate(dateStr: string): string {
|
|
429
|
+
const d = new Date(dateStr);
|
|
430
|
+
if (isNaN(d.getTime())) return dateStr;
|
|
431
|
+
return d.toLocaleDateString(undefined, {
|
|
432
|
+
year: "numeric",
|
|
433
|
+
month: "short",
|
|
434
|
+
day: "numeric",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* ────────────────────────────────────────────
|
|
439
|
+
* Main DocHistory component
|
|
440
|
+
* ──────────────────────────────────────────── */
|
|
441
|
+
|
|
442
|
+
export function DocHistory({ slug, locale, basePath = "/" }: DocHistoryProps) {
|
|
443
|
+
const [view, setView] = useState<PanelView>("closed");
|
|
444
|
+
const [data, setData] = useState<DocHistoryData | null>(null);
|
|
445
|
+
const [loading, setLoading] = useState(false);
|
|
446
|
+
const [error, setError] = useState<string | null>(null);
|
|
447
|
+
const [diffSelection, setDiffSelection] = useState<DiffSelection | null>(
|
|
448
|
+
null,
|
|
449
|
+
);
|
|
450
|
+
// Holds the history trigger button element captured in handleOpen() so the
|
|
451
|
+
// hook can restore focus to it when the dialog closes. We capture it there
|
|
452
|
+
// (synchronously in the click handler, before React re-renders) rather than
|
|
453
|
+
// inside the hook's effect because the button is unmounted when isOpen=true
|
|
454
|
+
// (conditional render) — by the time the open-effect fires, it's gone (#2295).
|
|
455
|
+
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
456
|
+
|
|
457
|
+
const base = basePath.replace(/\/+$/, "");
|
|
458
|
+
// Doc-history storage sentinel ("" -> "index"): a root index page has the
|
|
459
|
+
// canonical route slug "" (→ /docs/), but the per-page JSON is stored/served
|
|
460
|
+
// under "index" (an empty path segment is unroutable — the server regex
|
|
461
|
+
// /^\/doc-history\/(.+)\.json$/ rejects ""). The host wrapper already passes
|
|
462
|
+
// the sentineled slug, but defend the boundary so the component is correct
|
|
463
|
+
// for any caller. Mirrors `toHistorySlug` in @/utils/slug (inlined here
|
|
464
|
+
// rather than imported to keep this bundled island free of host-util
|
|
465
|
+
// coupling — see .template-drift-allowlist). (#1891)
|
|
466
|
+
const historySlug = slug === "" ? "index" : slug;
|
|
467
|
+
const fetchPath = locale
|
|
468
|
+
? `${base}/doc-history/${locale}/${historySlug}.json`
|
|
469
|
+
: `${base}/doc-history/${historySlug}.json`;
|
|
470
|
+
|
|
471
|
+
const fetchHistory = useCallback(async () => {
|
|
472
|
+
if (data) return; // already loaded
|
|
473
|
+
setLoading(true);
|
|
474
|
+
setError(null);
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch(fetchPath);
|
|
477
|
+
if (!res.ok) {
|
|
478
|
+
throw new Error(`Failed to load history (${res.status})`);
|
|
479
|
+
}
|
|
480
|
+
const json: DocHistoryData = await res.json();
|
|
481
|
+
if (!json || !Array.isArray(json.entries)) {
|
|
482
|
+
throw new Error("Malformed history response");
|
|
483
|
+
}
|
|
484
|
+
setData(json);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
setError(e instanceof Error ? e.message : "Failed to load history");
|
|
487
|
+
} finally {
|
|
488
|
+
setLoading(false);
|
|
489
|
+
}
|
|
490
|
+
}, [data, fetchPath]);
|
|
491
|
+
|
|
492
|
+
function handleOpen(e: React.MouseEvent<HTMLButtonElement>) {
|
|
493
|
+
// Capture the trigger so the dialog hook can restore focus to it on close
|
|
494
|
+
// (a11y #2295). The button now stays mounted while the panel is open (see
|
|
495
|
+
// the render below), so this ref stays a *connected* node — required for
|
|
496
|
+
// .focus() to actually land on close (zudolab/zudo-doc#2303).
|
|
497
|
+
returnFocusRef.current = e.currentTarget;
|
|
498
|
+
setView("revisions");
|
|
499
|
+
fetchHistory();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const handleClose = useCallback(() => {
|
|
503
|
+
setView("closed");
|
|
504
|
+
setDiffSelection(null);
|
|
505
|
+
}, []);
|
|
506
|
+
|
|
507
|
+
function handleSelectDiff(selection: DiffSelection) {
|
|
508
|
+
setDiffSelection(selection);
|
|
509
|
+
setView("diff");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function handleBackToRevisions() {
|
|
513
|
+
setDiffSelection(null);
|
|
514
|
+
setView("revisions");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Lock body scroll when panel is open
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
if (view !== "closed") {
|
|
520
|
+
document.body.style.overflow = "hidden";
|
|
521
|
+
} else {
|
|
522
|
+
document.body.style.overflow = "";
|
|
523
|
+
}
|
|
524
|
+
return () => {
|
|
525
|
+
document.body.style.overflow = "";
|
|
526
|
+
};
|
|
527
|
+
}, [view]);
|
|
528
|
+
|
|
529
|
+
const isOpen = view !== "closed";
|
|
530
|
+
const hasDiff = view === "diff" && diffSelection;
|
|
531
|
+
|
|
532
|
+
// Shared dialog lifecycle: showModal/close sync, native-close callback,
|
|
533
|
+
// and navigation-close — delegated to useModalDialog.
|
|
534
|
+
// manageFocus: move focus to the close button on open, restore to the
|
|
535
|
+
// history trigger button on close (a11y interaction #2295).
|
|
536
|
+
// returnFocusRef: pre-captured trigger element (button is unmounted when
|
|
537
|
+
// isOpen=true, so capture must happen in the click handler, not the effect).
|
|
538
|
+
const { dialogRef } = useModalDialog({
|
|
539
|
+
isOpen,
|
|
540
|
+
onClose: handleClose,
|
|
541
|
+
navigateEvent: AFTER_NAVIGATE_EVENT,
|
|
542
|
+
manageFocus: true,
|
|
543
|
+
returnFocusRef,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<>
|
|
548
|
+
{/* History button. Kept mounted even while the panel is open: it sits
|
|
549
|
+
behind the full-screen showModal() dialog, which renders the rest of
|
|
550
|
+
the document inert, so the button is neither visible nor tabbable
|
|
551
|
+
while open. It must stay mounted so `returnFocusRef` (captured in
|
|
552
|
+
handleOpen) remains a *connected* node — the dialog hook restores
|
|
553
|
+
focus to it on close (a11y #2295). Conditionally unmounting it
|
|
554
|
+
(the old `{!isOpen && …}`) left the ref pointing at a detached node,
|
|
555
|
+
so `.focus()` no-op'd and focus fell to <body> on close
|
|
556
|
+
(zudolab/zudo-doc#2303). */}
|
|
557
|
+
<div className="flex justify-end mt-vsp-xl">
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
onClick={handleOpen}
|
|
561
|
+
className="doc-history-trigger flex items-center gap-hsp-xs px-hsp-md py-vsp-xs rounded-lg bg-surface border border-muted text-muted hover:text-fg hover:border-fg transition-colors"
|
|
562
|
+
aria-label="View document history"
|
|
563
|
+
>
|
|
564
|
+
<History className="h-icon-md w-icon-md" />
|
|
565
|
+
<span className="text-small">History</span>
|
|
566
|
+
</button>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
{/* Full-screen dialog — renders in top layer, above all stacking contexts */}
|
|
570
|
+
{/* z-modal / backdrop:z-modal-backdrop are defense-in-depth for the
|
|
571
|
+
SPA-swap window (zfb Strategy-B `zfb:after-swap`): clicking a history
|
|
572
|
+
entry link swaps the page body while this dialog is still open, and a
|
|
573
|
+
native showModal() dialog can momentarily lose top-layer promotion and
|
|
574
|
+
fall back to z-index:auto, flashing behind the header/sidebar. The
|
|
575
|
+
explicit modal-tier z-index keeps it above all chrome during that
|
|
576
|
+
window. Intentionally redundant in the normal (top-layer) case — do
|
|
577
|
+
not remove as "redundant" (epic #2148 / issue #2157). */}
|
|
578
|
+
<dialog
|
|
579
|
+
ref={dialogRef}
|
|
580
|
+
aria-label="Document revision history"
|
|
581
|
+
className="doc-history-panel z-modal fixed inset-0 m-0 h-full w-full max-h-full max-w-full bg-bg border-none p-0 backdrop:z-modal-backdrop backdrop:bg-bg/30"
|
|
582
|
+
style={{ color: "var(--color-fg)" }}
|
|
583
|
+
>
|
|
584
|
+
{/* Panel header */}
|
|
585
|
+
<div className="flex items-center justify-between px-hsp-lg py-vsp-xs border-b border-muted">
|
|
586
|
+
<h2 className="text-body font-semibold text-fg">
|
|
587
|
+
{view === "diff" ? "Diff" : "Revision History"}
|
|
588
|
+
</h2>
|
|
589
|
+
<button
|
|
590
|
+
type="button"
|
|
591
|
+
onClick={handleClose}
|
|
592
|
+
className="text-muted hover:text-fg"
|
|
593
|
+
aria-label="Close history panel"
|
|
594
|
+
>
|
|
595
|
+
<Close className="h-icon-md w-icon-md" />
|
|
596
|
+
</button>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
{/* Panel body */}
|
|
600
|
+
<div className="h-[calc(100%-3rem)] overflow-hidden">
|
|
601
|
+
{loading && <Spinner />}
|
|
602
|
+
|
|
603
|
+
{error && (
|
|
604
|
+
<div className="px-hsp-lg py-vsp-lg text-danger text-small">
|
|
605
|
+
{error}
|
|
606
|
+
</div>
|
|
607
|
+
)}
|
|
608
|
+
|
|
609
|
+
{/* Difit-style LR split: revision sidebar | diff area */}
|
|
610
|
+
{!loading && !error && data && (
|
|
611
|
+
<div className="flex h-full">
|
|
612
|
+
{/* Left sidebar: revision list — always visible on lg */}
|
|
613
|
+
<div
|
|
614
|
+
className={
|
|
615
|
+
hasDiff
|
|
616
|
+
? "hidden lg:flex lg:flex-col lg:w-[clamp(16rem,25%,22rem)] shrink-0 border-r border-muted h-full"
|
|
617
|
+
: "flex flex-col w-full h-full"
|
|
618
|
+
}
|
|
619
|
+
>
|
|
620
|
+
<RevisionList
|
|
621
|
+
entries={data.entries}
|
|
622
|
+
onSelectDiff={handleSelectDiff}
|
|
623
|
+
/>
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
{/* Right: diff viewer (on mobile, replaces the sidebar) */}
|
|
627
|
+
{hasDiff && (
|
|
628
|
+
<div className="flex-1 min-w-0 h-full">
|
|
629
|
+
{/* Key on the compared pair forces a fresh mount whenever the
|
|
630
|
+
selection changes, so the previous pair's diff rows can
|
|
631
|
+
never render under the new header hashes while the lazy
|
|
632
|
+
diff recompute is in flight (#2068). */}
|
|
633
|
+
<DiffViewer
|
|
634
|
+
key={`${diffSelection.older.hash}:${diffSelection.newer.hash}`}
|
|
635
|
+
selection={diffSelection}
|
|
636
|
+
onBack={handleBackToRevisions}
|
|
637
|
+
showBackButton={true}
|
|
638
|
+
/>
|
|
639
|
+
</div>
|
|
640
|
+
)}
|
|
641
|
+
</div>
|
|
642
|
+
)}
|
|
643
|
+
</div>
|
|
644
|
+
</dialog>
|
|
645
|
+
</>
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
DocHistory.displayName = "DocHistory";
|