seemore 1.1.4 → 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/README.md +51 -29
- package/dist/cli/index.js +132 -12
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/app/features/edit.tsx +285 -0
- package/src/app/index.html +5 -2
- package/src/app/layout/DocsLayout.tsx +11 -7
- package/src/app/lib/pages.ts +78 -8
- package/src/app/router.tsx +18 -6
- package/src/app/styles/globals.css +64 -0
- package/src/shared/types.ts +3 -0
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
* Types shared by the node pipeline and the browser app. This file ships as source, next to
|
|
5
5
|
* `src/app`, so both halves agree on the shape of the virtual modules.
|
|
6
6
|
*/
|
|
7
|
-
declare const FEATURES: readonly ["navigation.instant.prefetch", "navigation.instant.preview", "navigation.footer", "navigation.top", "navigation.path", "navigation.sections", "navigation.prune", "toc.follow", "toc.integrate", "content.code.copy", "content.action.edit", "content.image.zoom", "search.suggest", "search.highlight", "social.cards"];
|
|
7
|
+
declare const FEATURES: readonly ["navigation.instant.prefetch", "navigation.instant.preview", "navigation.footer", "navigation.top", "navigation.path", "navigation.sections", "navigation.prune", "toc.follow", "toc.integrate", "content.code.copy", "content.action.edit", "content.edit", "content.image.zoom", "search.suggest", "search.highlight", "social.cards"];
|
|
8
8
|
type Feature = (typeof FEATURES)[number];
|
|
9
9
|
/** What a user may write in `features`: a flag, or `!flag` to switch a default-on flag off. */
|
|
10
10
|
type FeatureFlag = Feature | `!${Feature}`;
|
package/package.json
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { RouteEntry } from '../../shared/types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Editing a block of a page from the browser.
|
|
6
|
+
*
|
|
7
|
+
* What is edited is the block's **Markdown source**, not its rendered HTML. A rehype plugin
|
|
8
|
+
* stamped every editable block with its `start:end` offsets into the file, so a save replaces
|
|
9
|
+
* exactly those characters and every other byte of the file is left alone — no HTML-to-
|
|
10
|
+
* Markdown round trip to mangle a table, a fence or a link reference, and no whole-file
|
|
11
|
+
* reflow in the diff.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here re-renders the page. The dev server writes the file, the watcher notices, and
|
|
14
|
+
* the page hot-reloads through the same path an edit made in an editor takes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ENDPOINT = '/__seemore/source';
|
|
18
|
+
|
|
19
|
+
interface Editing {
|
|
20
|
+
element: HTMLElement;
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
/**
|
|
24
|
+
* The slice exactly as the server sent it, kept out of the DOM.
|
|
25
|
+
*
|
|
26
|
+
* A textarea reports its value with `\n` whatever was put into it, so a CRLF file's block
|
|
27
|
+
* cannot be compared against the copy that came back through the editor. This is the copy
|
|
28
|
+
* the server checks the file against.
|
|
29
|
+
*/
|
|
30
|
+
original: string;
|
|
31
|
+
top: number;
|
|
32
|
+
left: number;
|
|
33
|
+
width: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function InlineEditor({ entry }: { entry: RouteEntry }) {
|
|
37
|
+
const layer = useRef<HTMLDivElement | null>(null);
|
|
38
|
+
const textarea = useRef<HTMLTextAreaElement | null>(null);
|
|
39
|
+
const [editing, setEditing] = useState<Editing | undefined>(undefined);
|
|
40
|
+
/** Read inside the dblclick listener, which is bound once and would capture stale state. */
|
|
41
|
+
const editingRef = useRef<Editing | undefined>(undefined);
|
|
42
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
43
|
+
const [saving, setSaving] = useState(false);
|
|
44
|
+
/**
|
|
45
|
+
* Set when a save was refused, which in practice means the file moved under the offsets
|
|
46
|
+
* this editor is holding. It disarms the automatic save-on-blur: the text on screen is
|
|
47
|
+
* stale by definition, and leaving blur armed means a later click — or navigating away —
|
|
48
|
+
* silently commits it the moment the file happens to match `original` again.
|
|
49
|
+
*/
|
|
50
|
+
const [refused, setRefused] = useState(false);
|
|
51
|
+
|
|
52
|
+
const close = useCallback(() => {
|
|
53
|
+
setEditing((current) => {
|
|
54
|
+
current?.element.classList.remove('seemore-editing');
|
|
55
|
+
return undefined;
|
|
56
|
+
});
|
|
57
|
+
setError(undefined);
|
|
58
|
+
setRefused(false);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// The article is this layer's own parent, so there is no ref to thread down from the layout.
|
|
62
|
+
const article = () => layer.current?.parentElement ?? undefined;
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const host = article();
|
|
66
|
+
if (host === undefined) return;
|
|
67
|
+
|
|
68
|
+
const onDoubleClick = (event: MouseEvent) => {
|
|
69
|
+
const target = event.target as HTMLElement | null;
|
|
70
|
+
if (target === null) return;
|
|
71
|
+
// A link would have navigated on the first of the two clicks. Leave it alone; the rest
|
|
72
|
+
// of the paragraph around it still opens the editor.
|
|
73
|
+
if (target.closest('a') !== null) return;
|
|
74
|
+
|
|
75
|
+
const block = target.closest<HTMLElement>('[data-seemore-pos]');
|
|
76
|
+
if (block === null || !host.contains(block)) return;
|
|
77
|
+
|
|
78
|
+
// An editor is already open. Moving to another block is fine while nothing has been
|
|
79
|
+
// typed, but with unsaved text it would discard the edit without asking — so leave it
|
|
80
|
+
// where it is and let Save or Cancel decide.
|
|
81
|
+
const field = textarea.current;
|
|
82
|
+
if (field !== null && editingRef.current !== undefined) {
|
|
83
|
+
const original = editingRef.current.original;
|
|
84
|
+
if (field.value.replace(/\r\n/g, '\n') !== original.replace(/\r\n/g, '\n')) return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const [start, end] = (block.dataset['seemorePos'] ?? '').split(':').map(Number);
|
|
88
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return;
|
|
89
|
+
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
void open(block, start as number, end as number);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const open = async (block: HTMLElement, start: number, end: number) => {
|
|
95
|
+
const query = new URLSearchParams({ file: entry.absPath, start: String(start), end: String(end) });
|
|
96
|
+
let original: string;
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`${ENDPOINT}?${query.toString()}`);
|
|
99
|
+
const body = (await response.json()) as { text?: string; error?: string };
|
|
100
|
+
if (!response.ok || typeof body.text !== 'string') {
|
|
101
|
+
setError(body.error ?? 'Could not read this block from the file.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
original = body.text;
|
|
105
|
+
} catch {
|
|
106
|
+
setError('Could not reach the dev server.');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Measured against the article, which is the positioning context of the layer.
|
|
111
|
+
const host = article();
|
|
112
|
+
if (host === undefined) return;
|
|
113
|
+
const bounds = host.getBoundingClientRect();
|
|
114
|
+
const rect = block.getBoundingClientRect();
|
|
115
|
+
|
|
116
|
+
block.classList.add('seemore-editing');
|
|
117
|
+
setError(undefined);
|
|
118
|
+
setRefused(false);
|
|
119
|
+
setEditing({
|
|
120
|
+
element: block,
|
|
121
|
+
start,
|
|
122
|
+
end,
|
|
123
|
+
original,
|
|
124
|
+
top: rect.top - bounds.top,
|
|
125
|
+
left: rect.left - bounds.left,
|
|
126
|
+
width: rect.width,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
host.addEventListener('dblclick', onDoubleClick);
|
|
131
|
+
return () => host.removeEventListener('dblclick', onDoubleClick);
|
|
132
|
+
}, [entry.absPath]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
editingRef.current = editing;
|
|
136
|
+
}, [editing]);
|
|
137
|
+
|
|
138
|
+
// The block is hidden rather than removed while it is edited, so the page does not jump.
|
|
139
|
+
// If this component goes away mid-edit — a navigation, a hot reload — put it back.
|
|
140
|
+
useEffect(() => () => editing?.element.classList.remove('seemore-editing'), [editing]);
|
|
141
|
+
|
|
142
|
+
const save = useCallback(async () => {
|
|
143
|
+
const current = editing;
|
|
144
|
+
const field = textarea.current;
|
|
145
|
+
if (current === undefined || field === null || saving || refused) return;
|
|
146
|
+
|
|
147
|
+
// Both sides normalised: a textarea reports `\n` even for the `\r\n` a CRLF file gave it,
|
|
148
|
+
// so comparing raw would make every no-op edit on Windows look like a change and rewrite
|
|
149
|
+
// the file — same bytes, but a fresh mtime and a pointless reload.
|
|
150
|
+
if (field.value.replace(/\r\n/g, '\n') === current.original.replace(/\r\n/g, '\n')) {
|
|
151
|
+
close();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setSaving(true);
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(ENDPOINT, {
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
file: entry.absPath,
|
|
162
|
+
start: current.start,
|
|
163
|
+
end: current.end,
|
|
164
|
+
expected: current.original,
|
|
165
|
+
text: field.value,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
170
|
+
setError(body.error ?? 'The edit could not be saved.');
|
|
171
|
+
setRefused(true);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// The watcher takes it from here: the file changed, so the page reloads its own module.
|
|
175
|
+
close();
|
|
176
|
+
} catch {
|
|
177
|
+
setError('Could not reach the dev server.');
|
|
178
|
+
setRefused(true);
|
|
179
|
+
} finally {
|
|
180
|
+
setSaving(false);
|
|
181
|
+
}
|
|
182
|
+
}, [editing, entry.absPath, close, saving, refused]);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="seemore-editor-layer" ref={layer}>
|
|
186
|
+
{editing === undefined ? undefined : (
|
|
187
|
+
<div
|
|
188
|
+
className="seemore-editor"
|
|
189
|
+
// `nearest` scrolls only when the box does not already fit, so opening an editor on
|
|
190
|
+
// a block that is comfortably in view does not move the page under the reader.
|
|
191
|
+
ref={(node) => node?.scrollIntoView({ block: 'nearest' })}
|
|
192
|
+
style={{ top: editing.top, left: editing.left, width: editing.width }}
|
|
193
|
+
>
|
|
194
|
+
<textarea
|
|
195
|
+
ref={(node) => {
|
|
196
|
+
textarea.current = node;
|
|
197
|
+
if (node === null) return;
|
|
198
|
+
node.focus();
|
|
199
|
+
node.setSelectionRange(node.value.length, node.value.length);
|
|
200
|
+
resize(node);
|
|
201
|
+
requestAnimationFrame(() => resize(node));
|
|
202
|
+
}}
|
|
203
|
+
className="seemore-editor-input"
|
|
204
|
+
// One row, so `height: auto` in `resize` collapses to the content rather than to
|
|
205
|
+
// the two-row default a textarea otherwise floors itself at.
|
|
206
|
+
rows={1}
|
|
207
|
+
defaultValue={editing.original}
|
|
208
|
+
spellCheck={false}
|
|
209
|
+
disabled={saving}
|
|
210
|
+
onInput={(event) => resize(event.currentTarget)}
|
|
211
|
+
onKeyDown={(event) => {
|
|
212
|
+
if (event.key === 'Escape') {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
close();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// `metaKey` on macOS, `ctrlKey` everywhere else — accept either rather than
|
|
218
|
+
// sniffing the platform.
|
|
219
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
void save();
|
|
222
|
+
}
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
<div className="seemore-editor-actions">
|
|
226
|
+
<span className="seemore-editor-status">
|
|
227
|
+
{saving ? 'Saving…' : refused ? 'Not saved — copy your text, then reload the page' : undefined}
|
|
228
|
+
</span>
|
|
229
|
+
{/*
|
|
230
|
+
`onMouseDown` is prevented on both buttons so focus never leaves the textarea.
|
|
231
|
+
Taking focus would fire its blur handler, which saves — so a click on Cancel
|
|
232
|
+
would commit the very edit it is meant to discard.
|
|
233
|
+
*/}
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className="seemore-editor-button"
|
|
237
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
238
|
+
onClick={close}
|
|
239
|
+
>
|
|
240
|
+
Cancel
|
|
241
|
+
</button>
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
className="seemore-editor-button seemore-editor-button-primary"
|
|
245
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
246
|
+
onClick={() => void save()}
|
|
247
|
+
disabled={saving || refused}
|
|
248
|
+
>
|
|
249
|
+
Save
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{error === undefined ? undefined : (
|
|
256
|
+
<p
|
|
257
|
+
className="seemore-editor-error"
|
|
258
|
+
role="alert"
|
|
259
|
+
title="Dismiss"
|
|
260
|
+
onClick={() => setError(undefined)}
|
|
261
|
+
>
|
|
262
|
+
{error}
|
|
263
|
+
</p>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Grow with the content: the Markdown behind a block is rarely the height of the block.
|
|
271
|
+
*
|
|
272
|
+
* Capped at part of the window, because a block whose source runs longer than the viewport
|
|
273
|
+
* would otherwise push Save and Cancel off the bottom of the screen. Past the cap the
|
|
274
|
+
* textarea scrolls inside itself and the action bar stays put.
|
|
275
|
+
*/
|
|
276
|
+
function resize(node: HTMLTextAreaElement): void {
|
|
277
|
+
const cap = Math.max(MIN_EDITOR_HEIGHT, Math.round(window.innerHeight * 0.55));
|
|
278
|
+
node.style.height = 'auto';
|
|
279
|
+
const wanted = node.scrollHeight;
|
|
280
|
+
node.style.height = `${Math.min(wanted, cap)}px`;
|
|
281
|
+
node.style.overflowY = wanted > cap ? 'auto' : 'hidden';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Floor for the cap, so a very short window still shows a usable amount of text. */
|
|
285
|
+
const MIN_EDITOR_HEIGHT = 160;
|
package/src/app/index.html
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<!-- Default icon so a zero-config folder does not 404 on /favicon.ico. A favicon from
|
|
7
|
-
the config is injected at the marker below, after this link, and the last one wins.
|
|
8
|
-
|
|
7
|
+
the config is injected at the marker below, after this link, and the last one wins.
|
|
8
|
+
This is seemore's own mark, inlined from assets/icon.svg at the repo root — the same
|
|
9
|
+
artwork the editor extension ships as its icon. Inlined rather than linked so the
|
|
10
|
+
built site needs no extra request and no file in the user's folder. -->
|
|
11
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='28' fill='%236D5EF5'/%3E%3Crect x='30' y='26' width='52' height='68' rx='8' fill='%23ffffff' opacity='0.35' transform='rotate(-8 56 60)'/%3E%3Crect x='38' y='22' width='52' height='68' rx='8' fill='%23ffffff' opacity='0.6' transform='rotate(-3 64 56)'/%3E%3Crect x='46' y='24' width='52' height='72' rx='8' fill='%23ffffff'/%3E%3Cg stroke-linecap='round'%3E%3Cline x1='56' y1='42' x2='82' y2='42' stroke='%236D5EF5' stroke-width='6'/%3E%3Cline x1='56' y1='56' x2='88' y2='56' stroke='%23C6C8E6' stroke-width='4'/%3E%3Cline x1='56' y1='66' x2='80' y2='66' stroke='%23C6C8E6' stroke-width='4'/%3E%3Cline x1='56' y1='76' x2='88' y2='76' stroke='%236D5EF5' stroke-width='4'/%3E%3Cline x1='56' y1='86' x2='74' y2='86' stroke='%23C6C8E6' stroke-width='4'/%3E%3C/g%3E%3C/svg%3E" />
|
|
9
12
|
<!--seemore-head-->
|
|
10
13
|
</head>
|
|
11
14
|
<body>
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { use } from 'react';
|
|
2
1
|
import type * as PageTree from 'fumadocs-core/page-tree';
|
|
3
2
|
import { TreeContextProvider } from 'fumadocs-ui/contexts/tree';
|
|
4
3
|
import { SidebarProvider } from 'fumadocs-ui/components/sidebar/base';
|
|
5
4
|
import { ArrowRight, Pencil } from 'lucide-react';
|
|
6
5
|
import { config } from 'virtual:seemore/config';
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
6
|
+
import type { RouteEntry } from '../../shared/types.js';
|
|
7
|
+
import { usePageModule } from '../lib/pages.js';
|
|
9
8
|
import { useRouteUrl } from '../router.js';
|
|
10
9
|
import { feature } from '../lib/features.js';
|
|
11
10
|
import { pruneTree, usePageTree } from '../lib/tree.js';
|
|
@@ -14,6 +13,7 @@ import { usePrefetch } from '../features/prefetch.js';
|
|
|
14
13
|
import { PagePreview } from '../features/preview.js';
|
|
15
14
|
import { useSearchHighlight } from '../features/highlight.js';
|
|
16
15
|
import { useHashScroll } from '../features/anchors.js';
|
|
16
|
+
import { InlineEditor } from '../features/edit.js';
|
|
17
17
|
import { SeemoreProvider } from './Provider.js';
|
|
18
18
|
import { Header } from './Header.js';
|
|
19
19
|
import { Sidebar } from './Sidebar.js';
|
|
@@ -43,9 +43,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
|
43
43
|
const url = useRouteUrl();
|
|
44
44
|
const tree = feature('navigation.prune') ? pruneTree(full, url) : full;
|
|
45
45
|
|
|
46
|
-
// `use()` on the cached module promise: already-loaded pages render
|
|
47
|
-
// is what makes `renderToString` emit a complete page.
|
|
48
|
-
const page =
|
|
46
|
+
// `use()` on the cached module promise, inside the hook: already-loaded pages render
|
|
47
|
+
// synchronously, which is what makes `renderToString` emit a complete page.
|
|
48
|
+
const page = usePageModule(entry);
|
|
49
49
|
const Content = page.default;
|
|
50
50
|
|
|
51
51
|
usePrefetch();
|
|
@@ -53,6 +53,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
|
53
53
|
useHashScroll();
|
|
54
54
|
|
|
55
55
|
const integrated = feature('toc.integrate');
|
|
56
|
+
// Dev only, and off unless asked for: the editor writes to the user's files, and a static
|
|
57
|
+
// build has no server to write through. `import.meta.env.DEV` also keeps it out of the bundle.
|
|
58
|
+
const editable = import.meta.env.DEV && feature('content.edit');
|
|
56
59
|
|
|
57
60
|
return (
|
|
58
61
|
<TocProvider toc={page.toc ?? []}>
|
|
@@ -63,8 +66,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
|
63
66
|
|
|
64
67
|
<main className="seemore-main">
|
|
65
68
|
{feature('navigation.path') ? <Breadcrumb /> : undefined}
|
|
66
|
-
<article className=
|
|
69
|
+
<article className={editable ? 'seemore-article prose seemore-editable' : 'seemore-article prose'}>
|
|
67
70
|
<Content components={mdxComponents} />
|
|
71
|
+
{editable ? <InlineEditor key={entry.url} entry={entry} /> : undefined}
|
|
68
72
|
</article>
|
|
69
73
|
|
|
70
74
|
{config.editLink !== undefined && feature('content.action.edit') ? (
|
package/src/app/lib/pages.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useSyncExternalStore } from 'react';
|
|
1
|
+
import { use, useSyncExternalStore } from 'react';
|
|
2
2
|
import { getRoutes, subscribeRoutes } from 'virtual:seemore/routes';
|
|
3
3
|
import type { PageModule, RouteEntry } from '../../shared/types.js';
|
|
4
4
|
|
|
@@ -8,26 +8,48 @@ import type { PageModule, RouteEntry } from '../../shared/types.js';
|
|
|
8
8
|
* The promise is annotated with `status`/`value`, the convention React's `use()` reads, so a
|
|
9
9
|
* module that is already loaded renders synchronously — which is what lets `renderToString`
|
|
10
10
|
* produce a complete page with no Suspense fallback in the output.
|
|
11
|
+
*
|
|
12
|
+
* Entries are keyed by URL and stamped with the route's content `version`. A URL can outlive
|
|
13
|
+
* its module: in dev, a body edit keeps the address and replaces the file behind it, and the
|
|
14
|
+
* cached promise would be the last thing still holding the old component. Fast Refresh does
|
|
15
|
+
* not step in — MDX emits a named `toc` export beside the default one, so the React plugin
|
|
16
|
+
* declines the module and invalidates it instead, and that invalidation is absorbed by the
|
|
17
|
+
* route store's own `accept()`. The version is how the cache notices on its own.
|
|
11
18
|
*/
|
|
12
19
|
type Tracked = Promise<PageModule> & {
|
|
13
20
|
status?: 'pending' | 'fulfilled' | 'rejected';
|
|
14
21
|
value?: PageModule;
|
|
15
22
|
reason?: unknown;
|
|
23
|
+
version: string;
|
|
24
|
+
/** A replacement already loading for a newer version, so an edit is fetched once. */
|
|
25
|
+
next?: Tracked;
|
|
16
26
|
};
|
|
17
27
|
|
|
18
28
|
const cache = new Map<string, Tracked>();
|
|
19
29
|
|
|
20
30
|
let index = buildIndex();
|
|
21
31
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
32
|
+
// Bumped whenever a cached module is swapped for a newer version, so a page that is on
|
|
33
|
+
// screen re-renders onto the new one. Route changes have their own store; this one is only
|
|
34
|
+
// for replacements, which arrive later, once the new module has actually loaded.
|
|
35
|
+
let generation = 0;
|
|
36
|
+
const swapListeners = new Set<() => void>();
|
|
37
|
+
|
|
38
|
+
subscribeRoutes(() => onRoutesChanged());
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rebuild the lookup and drop pages that no longer exist. A page that still exists keeps its
|
|
42
|
+
* entry even if its content changed: the version check in `loadPage` handles that, and it
|
|
43
|
+
* keeps the old module on screen until the new one is ready rather than dropping to a
|
|
44
|
+
* loading fallback. Exported for the pages test.
|
|
45
|
+
*/
|
|
46
|
+
export function onRoutesChanged(): void {
|
|
25
47
|
index = buildIndex();
|
|
26
48
|
// Deleting the current entry while iterating a Map is well defined.
|
|
27
49
|
for (const url of cache.keys()) {
|
|
28
50
|
if (!index.has(url)) cache.delete(url);
|
|
29
51
|
}
|
|
30
|
-
}
|
|
52
|
+
}
|
|
31
53
|
|
|
32
54
|
function buildIndex(): Map<string, RouteEntry> {
|
|
33
55
|
return new Map(getRoutes().map((entry) => [entry.url, entry]));
|
|
@@ -47,11 +69,61 @@ export function useRouteEntry(url: string): RouteEntry | undefined {
|
|
|
47
69
|
return entries.find((entry) => entry.url === url);
|
|
48
70
|
}
|
|
49
71
|
|
|
72
|
+
/** The page's module, re-rendering when an edit replaces it. Suspends until first loaded. */
|
|
73
|
+
export function usePageModule(entry: RouteEntry): PageModule {
|
|
74
|
+
useSyncExternalStore(subscribeSwaps, getGeneration, getGeneration);
|
|
75
|
+
return use(loadPage(entry) as Promise<PageModule>);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Exported for the pages test; components go through `usePageModule`. */
|
|
79
|
+
export function subscribeSwaps(listener: () => void): () => void {
|
|
80
|
+
swapListeners.add(listener);
|
|
81
|
+
return () => {
|
|
82
|
+
swapListeners.delete(listener);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getGeneration(): number {
|
|
87
|
+
return generation;
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
export function loadPage(entry: RouteEntry): Tracked {
|
|
51
91
|
const existing = cache.get(entry.url);
|
|
52
|
-
if (existing
|
|
92
|
+
if (existing === undefined) {
|
|
93
|
+
const fresh = track(entry);
|
|
94
|
+
cache.set(entry.url, fresh);
|
|
95
|
+
return fresh;
|
|
96
|
+
}
|
|
97
|
+
if (existing.version === entry.version) return existing;
|
|
98
|
+
|
|
99
|
+
// Only a rendered module is worth keeping on screen while its replacement loads. A pending
|
|
100
|
+
// or failed one is not: hand over immediately, so a fixed file suspends on the fix instead
|
|
101
|
+
// of re-throwing the error it just corrected.
|
|
102
|
+
if (existing.status !== 'fulfilled') {
|
|
103
|
+
const fresh = track(entry);
|
|
104
|
+
cache.set(entry.url, fresh);
|
|
105
|
+
return fresh;
|
|
106
|
+
}
|
|
53
107
|
|
|
108
|
+
if (existing.next?.version !== entry.version) {
|
|
109
|
+
const fresh = track(entry);
|
|
110
|
+
existing.next = fresh;
|
|
111
|
+
const settle = () => {
|
|
112
|
+
// The page may have been deleted, or this URL may already be on a later version — the
|
|
113
|
+
// next render compares versions again, so the only wrong move is resurrecting a URL.
|
|
114
|
+
if (!index.has(entry.url)) return;
|
|
115
|
+
cache.set(entry.url, fresh);
|
|
116
|
+
generation += 1;
|
|
117
|
+
for (const listener of swapListeners) listener();
|
|
118
|
+
};
|
|
119
|
+
fresh.then(settle, settle);
|
|
120
|
+
}
|
|
121
|
+
return existing;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function track(entry: RouteEntry): Tracked {
|
|
54
125
|
const promise = entry.load() as Tracked;
|
|
126
|
+
promise.version = entry.version;
|
|
55
127
|
promise.status = 'pending';
|
|
56
128
|
promise.then(
|
|
57
129
|
(value) => {
|
|
@@ -63,8 +135,6 @@ export function loadPage(entry: RouteEntry): Tracked {
|
|
|
63
135
|
promise.reason = reason;
|
|
64
136
|
},
|
|
65
137
|
);
|
|
66
|
-
|
|
67
|
-
cache.set(entry.url, promise);
|
|
68
138
|
return promise;
|
|
69
139
|
}
|
|
70
140
|
|
package/src/app/router.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Component, Suspense, type ReactNode } from 'react';
|
|
2
2
|
import { useLocation, type RouteObject } from 'react-router';
|
|
3
3
|
import { decodePath } from '../shared/base.js';
|
|
4
|
+
import type { RouteEntry } from '../shared/types.js';
|
|
4
5
|
import { DocPage, DocsLayout, NotFound, Overview, PageError } from './layout/DocsLayout.js';
|
|
5
6
|
import { useRouteEntry } from './lib/pages.js';
|
|
6
7
|
|
|
@@ -13,17 +14,19 @@ export function useRouteUrl(): string {
|
|
|
13
14
|
|
|
14
15
|
function Page() {
|
|
15
16
|
const url = useRouteUrl();
|
|
17
|
+
const entry = useRouteEntry(url);
|
|
16
18
|
// Keyed by address, so navigating away from a page that threw starts clean rather than
|
|
17
|
-
// carrying its error to every page after it.
|
|
19
|
+
// carrying its error to every page after it. Reset by content version, so in dev an edit
|
|
20
|
+
// that fixes the file gets to render — a boundary in its error state has unmounted the
|
|
21
|
+
// children, and nothing else is left in the tree to try again.
|
|
18
22
|
return (
|
|
19
|
-
<PageErrorBoundary key={url}>
|
|
20
|
-
<PageContent url={url} />
|
|
23
|
+
<PageErrorBoundary key={url} resetKey={entry?.version}>
|
|
24
|
+
<PageContent url={url} entry={entry} />
|
|
21
25
|
</PageErrorBoundary>
|
|
22
26
|
);
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
function PageContent({ url }: { url: string }) {
|
|
26
|
-
const entry = useRouteEntry(url);
|
|
29
|
+
function PageContent({ url, entry }: { url: string; entry: RouteEntry | undefined }) {
|
|
27
30
|
if (entry !== undefined) return <DocPage entry={entry} />;
|
|
28
31
|
// A folder with no `index.md` or root `README.md` still gets a home address: a generated
|
|
29
32
|
// list of every page, not an apology.
|
|
@@ -34,13 +37,22 @@ function PageContent({ url }: { url: string }) {
|
|
|
34
37
|
* The only error boundary in the app. Render errors come from page content — the rest of the
|
|
35
38
|
* tree is seemore's own — so this sits around the page and nothing else.
|
|
36
39
|
*/
|
|
37
|
-
class PageErrorBoundary extends Component<
|
|
40
|
+
class PageErrorBoundary extends Component<
|
|
41
|
+
{ children: ReactNode; resetKey: string | undefined },
|
|
42
|
+
{ message: string | undefined }
|
|
43
|
+
> {
|
|
38
44
|
override state: { message: string | undefined } = { message: undefined };
|
|
39
45
|
|
|
40
46
|
static getDerivedStateFromError(error: unknown): { message: string } {
|
|
41
47
|
return { message: error instanceof Error ? error.message : String(error) };
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
override componentDidUpdate(previous: { resetKey: string | undefined }): void {
|
|
51
|
+
if (this.state.message !== undefined && previous.resetKey !== this.props.resetKey) {
|
|
52
|
+
this.setState({ message: undefined });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
override render() {
|
|
45
57
|
if (this.state.message !== undefined) return <PageError message={this.state.message} />;
|
|
46
58
|
return this.props.children;
|
|
@@ -292,6 +292,70 @@
|
|
|
292
292
|
@apply my-6 rounded-lg border border-fd-border p-4 text-sm text-fd-muted-foreground;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/* Inline editing (dev only, `content.edit`). The article is the positioning context for
|
|
296
|
+
the editor layer, which sits over the block being edited. */
|
|
297
|
+
.seemore-editable {
|
|
298
|
+
@apply relative;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.seemore-editable [data-seemore-pos]:hover {
|
|
302
|
+
@apply cursor-text rounded-sm bg-fd-muted/40;
|
|
303
|
+
box-shadow: 0 0 0 4px var(--color-fd-muted);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.seemore-editable .seemore-editing {
|
|
307
|
+
@apply invisible;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.seemore-editor-layer {
|
|
311
|
+
@apply pointer-events-none absolute inset-0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* `scroll-mt-20` clears the sticky header: without it, scrolling a tall editor into view
|
|
315
|
+
tucks its first lines underneath the header bar. */
|
|
316
|
+
.seemore-editor {
|
|
317
|
+
@apply pointer-events-auto absolute z-20 scroll-mt-20 rounded-lg border border-fd-primary/40 bg-fd-background shadow-lg;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.seemore-editor-input {
|
|
321
|
+
@apply block w-full resize-none bg-transparent p-3 font-mono text-sm leading-relaxed text-fd-foreground outline-none;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.seemore-editor-actions {
|
|
325
|
+
@apply flex items-center justify-end gap-2 border-t border-fd-border px-2.5 py-2;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.seemore-editor-status {
|
|
329
|
+
@apply me-auto ps-0.5 text-xs text-fd-muted-foreground;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.seemore-editor-button {
|
|
333
|
+
@apply rounded-md border border-fd-border px-3 py-1 text-xs font-medium text-fd-foreground transition-colors;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.seemore-editor-button:hover:not(:disabled) {
|
|
337
|
+
@apply bg-fd-accent text-fd-accent-foreground;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.seemore-editor-button:disabled {
|
|
341
|
+
@apply cursor-not-allowed opacity-50;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.seemore-editor-button-primary {
|
|
345
|
+
@apply border-transparent bg-fd-primary text-fd-primary-foreground;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.seemore-editor-button-primary:hover:not(:disabled) {
|
|
349
|
+
@apply bg-fd-primary/90 text-fd-primary-foreground;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* Anchored to the viewport, not to the layer: the layer is `absolute inset-0` over the
|
|
353
|
+
article, so a `sticky` child of it renders at the article's top and lands on the
|
|
354
|
+
heading. A conflict is rare and worth reading, so it sits out of the content's way. */
|
|
355
|
+
.seemore-editor-error {
|
|
356
|
+
@apply pointer-events-auto fixed bottom-6 left-1/2 z-50 max-w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 cursor-pointer rounded-lg border border-fd-border bg-fd-background px-4 py-2.5 text-sm text-fd-foreground shadow-xl;
|
|
357
|
+
}
|
|
358
|
+
|
|
295
359
|
.seemore-pdf {
|
|
296
360
|
@apply my-6 block;
|
|
297
361
|
}
|
package/src/shared/types.ts
CHANGED
|
@@ -17,6 +17,7 @@ export const FEATURES = [
|
|
|
17
17
|
'toc.integrate',
|
|
18
18
|
'content.code.copy',
|
|
19
19
|
'content.action.edit',
|
|
20
|
+
'content.edit',
|
|
20
21
|
'content.image.zoom',
|
|
21
22
|
'search.suggest',
|
|
22
23
|
'search.highlight',
|
|
@@ -62,6 +63,8 @@ export interface RouteEntry {
|
|
|
62
63
|
absPath: string;
|
|
63
64
|
title: string;
|
|
64
65
|
description: string | null;
|
|
66
|
+
/** Content hash; a new value means `load()` now resolves to a different module. */
|
|
67
|
+
version: string;
|
|
65
68
|
load: () => Promise<PageModule>;
|
|
66
69
|
}
|
|
67
70
|
|