astro-dev-edit 0.11.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/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astro-dev-edit overlay — injected browser client (composition root).
|
|
3
|
+
*
|
|
4
|
+
* Edit mode gives you a hover highlight, and clicking routes by classification.
|
|
5
|
+
* The DOM-side guess is only a hover hint: every click is confirmed against the
|
|
6
|
+
* server's AST-truth /classify before an editor opens (a resolved {expression}
|
|
7
|
+
* looks identical to literal text in the DOM). (spec §7.3, §16.1)
|
|
8
|
+
* - literal text → inline contenteditable (editors/text.ts)
|
|
9
|
+
* - image → swap panel (editors/image.ts)
|
|
10
|
+
* - dynamic/other → a refusal notice with "Open source" (editors/notice.ts)
|
|
11
|
+
* Commits POST /apply, which verifies the source still matches what the client
|
|
12
|
+
* saw and patches the file atomically. Astro HMR then reloads the page from
|
|
13
|
+
* disk; edit mode survives the reload via sessionStorage.
|
|
14
|
+
*
|
|
15
|
+
* This module only wires the pieces together: source-map capture, hover,
|
|
16
|
+
* click routing, the admin bar, edit mode, and boot. Vanilla TS, no framework,
|
|
17
|
+
* no dependencies. (spec §4.2)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PageSourceResponse, SourceLoc } from '../shared/protocol.ts';
|
|
21
|
+
import { initAdminBar } from './admin-bar.ts';
|
|
22
|
+
import * as api from './api.ts';
|
|
23
|
+
import { invalidateClassifications } from './classify-cache.ts';
|
|
24
|
+
import {
|
|
25
|
+
clearPendingCollection,
|
|
26
|
+
openCollectionsPanel,
|
|
27
|
+
takePendingCollection,
|
|
28
|
+
} from './editors/collections-panel.ts';
|
|
29
|
+
import { openCopyPanel } from './editors/copy-panel.ts';
|
|
30
|
+
import { openEntryPanel, resumePendingNavigation } from './editors/entry.ts';
|
|
31
|
+
import { openPeekPanel } from './editors/peek.ts';
|
|
32
|
+
import { openSettingsPanel } from './editors/settings-panel.ts';
|
|
33
|
+
import { collectContext, formatContext } from './element-context.ts';
|
|
34
|
+
import { has, setFeatures } from './features.ts';
|
|
35
|
+
import { clearHighlight, initHover } from './hover.ts';
|
|
36
|
+
import { onPageSourceChange, pageSource, resolvePageSource } from './page-source.ts';
|
|
37
|
+
import { initRouter } from './router.ts';
|
|
38
|
+
import { cacheSourceMappings, sourceFor, startCapture } from './source-map.ts';
|
|
39
|
+
import { initTree } from './tree.ts';
|
|
40
|
+
import * as state from './state.ts';
|
|
41
|
+
import { mount } from './shadow.ts';
|
|
42
|
+
import { basename, toast } from './ui.ts';
|
|
43
|
+
|
|
44
|
+
// Begin capturing source annotations as early as possible. If the body isn't
|
|
45
|
+
// parsed yet, wait for it; the observer then catches every annotated node as
|
|
46
|
+
// it arrives. This must run synchronously at module evaluation to win the
|
|
47
|
+
// dev-toolbar attribute-strip race — see source-map.ts.
|
|
48
|
+
if (document.body) {
|
|
49
|
+
startCapture();
|
|
50
|
+
} else {
|
|
51
|
+
document.addEventListener('DOMContentLoaded', startCapture, { once: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Edit mode
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
let editMode = false;
|
|
59
|
+
// Whether the element tree should be open while editing. The tree does NOT ride
|
|
60
|
+
// along with edit mode — it stays closed until asked for (the bar's Elements
|
|
61
|
+
// button or the panel's edge tab), so entering edit mode never covers the page
|
|
62
|
+
// you came to edit. The choice does survive the full-page reload that follows
|
|
63
|
+
// every save, like edit mode itself.
|
|
64
|
+
let treeWanted = false;
|
|
65
|
+
// From /health: the absolute project root, so copied source paths come out
|
|
66
|
+
// repo-relative (Astro's annotations are absolute). Null until boot completes.
|
|
67
|
+
let projectRoot: string | null = null;
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Navigate-while-held: holding Ctrl or Alt/Option suspends editing so clicks
|
|
71
|
+
// travel the site normally, without toggling edit mode off and back on.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const IS_MAC = /Mac|iP(hone|ad|od)/.test(navigator.platform);
|
|
75
|
+
const NAV_HINT = IS_MAC ? 'hold ⌃ or ⌥ to navigate' : 'hold Ctrl to navigate';
|
|
76
|
+
|
|
77
|
+
let navigating = false;
|
|
78
|
+
|
|
79
|
+
function refreshCursor(): void {
|
|
80
|
+
document.body.style.cursor = editMode && !navigating ? 'crosshair' : '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setNavigating(on: boolean): void {
|
|
84
|
+
const next = on && editMode;
|
|
85
|
+
if (navigating === next) return;
|
|
86
|
+
navigating = next;
|
|
87
|
+
refreshCursor();
|
|
88
|
+
// The bar carries the hint, so hold-to-navigate isn't completely hidden.
|
|
89
|
+
bar.setHint(navigating ? 'release to edit' : NAV_HINT);
|
|
90
|
+
if (navigating) clearHighlight();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Track the modifier via keydown/keyup, with two fallbacks: window blur
|
|
94
|
+
// (app switch mid-hold) releases the mode, and mousemove re-syncs from the
|
|
95
|
+
// event's own modifier flags in case the keydown fired while focus was
|
|
96
|
+
// elsewhere. Capture phase, so the sync runs before hover's own listener.
|
|
97
|
+
const NAV_KEYS = new Set(['Control', 'Alt']);
|
|
98
|
+
document.addEventListener('keydown', (e) => {
|
|
99
|
+
if (!editMode || !NAV_KEYS.has(e.key)) return;
|
|
100
|
+
// A bare Alt keydown would otherwise focus the browser menu bar on keyup
|
|
101
|
+
// (Firefox/Windows).
|
|
102
|
+
if (e.key === 'Alt') e.preventDefault();
|
|
103
|
+
setNavigating(true);
|
|
104
|
+
}, true);
|
|
105
|
+
document.addEventListener('keyup', (e) => {
|
|
106
|
+
// If both modifiers were held, the flags of the still-held one keep it on.
|
|
107
|
+
if (NAV_KEYS.has(e.key)) setNavigating(e.ctrlKey || e.altKey);
|
|
108
|
+
}, true);
|
|
109
|
+
window.addEventListener('blur', () => setNavigating(false));
|
|
110
|
+
document.addEventListener('mousemove', (e) => {
|
|
111
|
+
if (editMode) setNavigating(e.ctrlKey || e.altKey);
|
|
112
|
+
}, true);
|
|
113
|
+
|
|
114
|
+
/** Remember whether the tree is wanted, across the save-triggered reload. */
|
|
115
|
+
function rememberTree(open: boolean): void {
|
|
116
|
+
treeWanted = open;
|
|
117
|
+
try {
|
|
118
|
+
sessionStorage.setItem('astroDevEditTree', open ? '1' : '0');
|
|
119
|
+
} catch {
|
|
120
|
+
// sessionStorage unavailable (rare) — the choice just won't persist.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setEditMode(on: boolean): void {
|
|
125
|
+
editMode = on;
|
|
126
|
+
if (!on) setNavigating(false);
|
|
127
|
+
refreshCursor();
|
|
128
|
+
// Survive the full-page reload that follows every successful save.
|
|
129
|
+
try {
|
|
130
|
+
sessionStorage.setItem('astroDevEditMode', on ? '1' : '0');
|
|
131
|
+
} catch {
|
|
132
|
+
// sessionStorage unavailable (rare) — edit mode just won't persist.
|
|
133
|
+
}
|
|
134
|
+
if (on) {
|
|
135
|
+
tree.rebuild();
|
|
136
|
+
// Closed by default: hide() is what puts the edge tab up now that editing
|
|
137
|
+
// is on, so the tree is one click away without being in the way.
|
|
138
|
+
if (treeWanted) tree.show();
|
|
139
|
+
else tree.hide();
|
|
140
|
+
} else {
|
|
141
|
+
clearHighlight();
|
|
142
|
+
tree.hide();
|
|
143
|
+
// Only panels can still be open here: every exit path commits an inline
|
|
144
|
+
// edit first (exitEditing), so this can no longer discard typing.
|
|
145
|
+
state.dismiss();
|
|
146
|
+
}
|
|
147
|
+
bar.setHint(on ? NAV_HINT : null);
|
|
148
|
+
bar.refresh();
|
|
149
|
+
// Edit mode holds the bar out whether or not it is pinned — it carries the
|
|
150
|
+
// save state and the way out — so the retract state has to be recomputed.
|
|
151
|
+
bar.syncVisibility();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The single way out of edit mode. Anything pending is written first and we
|
|
155
|
+
// only leave once the write has landed, so "am I done?" is answered by the
|
|
156
|
+
// bar's exit button rather than by hoping. A cancel (Escape) is the *other*
|
|
157
|
+
// path and stays deliberate — it is the only way to throw a change away.
|
|
158
|
+
let settleWatcher: (() => void) | null = null;
|
|
159
|
+
|
|
160
|
+
function exitEditing(): void {
|
|
161
|
+
if (state.savePhase() === 'saving') {
|
|
162
|
+
leaveWhenSettled();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (state.get()) {
|
|
166
|
+
state.commit(); // a text edit saves; a panel just closes
|
|
167
|
+
if (state.savePhase() === 'saving') {
|
|
168
|
+
leaveWhenSettled();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
setEditMode(false);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Hold edit mode open until the in-flight write settles, then leave — unless
|
|
176
|
+
* it failed, in which case stay so the red exit button is there to be read. */
|
|
177
|
+
function leaveWhenSettled(): void {
|
|
178
|
+
if (settleWatcher) return;
|
|
179
|
+
settleWatcher = state.onSavePhase((phase) => {
|
|
180
|
+
if (phase === 'saving') return;
|
|
181
|
+
settleWatcher?.();
|
|
182
|
+
settleWatcher = null;
|
|
183
|
+
if (phase !== 'error') setEditMode(false);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Global Escape closes any open modal interaction (image/dynamic panel) and
|
|
188
|
+
// guarantees state resets, then clears a locked element-tree selection if one
|
|
189
|
+
// is the only thing open. Inline text edits handle their own Escape (to restore
|
|
190
|
+
// original text) before this ever sees it.
|
|
191
|
+
document.addEventListener('keydown', (e) => {
|
|
192
|
+
if (e.key !== 'Escape') return;
|
|
193
|
+
if (state.get()?.kind === 'panel') {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
state.dismiss();
|
|
196
|
+
} else if (tree.hasSelection()) {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
tree.clearSelection();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Wiring
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** Open a source location in the user's editor, reporting the result. */
|
|
207
|
+
async function openSource(src: SourceLoc): Promise<void> {
|
|
208
|
+
try {
|
|
209
|
+
await api.open({ file: src.file, loc: src.loc });
|
|
210
|
+
toast(`Opened ${basename(src.file)}:${src.loc} in your editor`, 'ok');
|
|
211
|
+
} catch (err) {
|
|
212
|
+
toast(`Could not open source — ${err instanceof Error ? err.message : 'unknown'}`, 'err');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What each refusal means in the one sentence the user sees. */
|
|
217
|
+
const PAGE_SOURCE_REFUSALS: Record<
|
|
218
|
+
NonNullable<PageSourceResponse['refusal']>,
|
|
219
|
+
(pathname: string) => string
|
|
220
|
+
> = {
|
|
221
|
+
'no-routes': () => 'Astro reported no routes — cannot tell which file this page is',
|
|
222
|
+
'no-match': (p) => `No route matches ${p} — cannot tell which file this page is`,
|
|
223
|
+
'not-in-project': (p) => `The route for ${p} is not a file in this project`,
|
|
224
|
+
missing: (p) => `The route for ${p} has no source file on disk`,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Open the file this page is written in — the bar menu's "Open page source".
|
|
229
|
+
*
|
|
230
|
+
* The server answers from Astro's route manifest, because the DOM cannot: only
|
|
231
|
+
* elements are annotated, never component tags, so the old approach of opening
|
|
232
|
+
* whichever file rendered the most annotated elements landed on a markup-dense
|
|
233
|
+
* Nav.astro instead of a page that mostly composes components. When no route
|
|
234
|
+
* matches, we say so and open nothing rather than guess.
|
|
235
|
+
*/
|
|
236
|
+
async function openPageSource(): Promise<void> {
|
|
237
|
+
const pathname = location.pathname;
|
|
238
|
+
let answer: PageSourceResponse;
|
|
239
|
+
try {
|
|
240
|
+
answer = await api.resolvePageSource({ pathname });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
toast(`Could not locate this page — ${err instanceof Error ? err.message : 'unknown'}`, 'err');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!answer.file) {
|
|
246
|
+
toast(PAGE_SOURCE_REFUSALS[answer.refusal ?? 'no-match'](pathname), 'err');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await api.open({ file: answer.file, loc: '1:1' });
|
|
251
|
+
} catch (err) {
|
|
252
|
+
toast(`Could not open source — ${err instanceof Error ? err.message : 'unknown'}`, 'err');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// The whole root-relative path, not just the basename: half a project's pages
|
|
256
|
+
// are called index.astro, and this is the one toast whose job is to say which.
|
|
257
|
+
const where = answer.pattern ? ` — the template for ${answer.pattern}` : '';
|
|
258
|
+
toast(`Opened ${answer.file}${where}`, 'ok');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Open a CSS rule's source in the editor (hover-pill inspector), reporting the
|
|
262
|
+
* result — the server jumps to the located line, or the file top on a miss. */
|
|
263
|
+
async function openRule(file: string, selector: string): Promise<void> {
|
|
264
|
+
try {
|
|
265
|
+
const { loc } = await api.inspectOpen({ file, selector });
|
|
266
|
+
const where = loc ? `${basename(file)}:${loc}` : basename(file);
|
|
267
|
+
toast(`Opened ${where} in your editor`, 'ok');
|
|
268
|
+
} catch (err) {
|
|
269
|
+
toast(`Could not open ${selector} — ${err instanceof Error ? err.message : 'unknown'}`, 'err');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Human-readable size for the copy toast — the payload's bulk is the one thing
|
|
274
|
+
* you can't see from the button. */
|
|
275
|
+
function sizeLabel(text: string): string {
|
|
276
|
+
return text.length < 1024 ? `${text.length} characters` : `${(text.length / 1024).toFixed(1)} KB`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The hover pill's "copy": gather the element's context and write it to the
|
|
281
|
+
* clipboard. Resolves true only on a real clipboard write; when the API is
|
|
282
|
+
* missing (a dev server reached over the network is not a secure context) or
|
|
283
|
+
* refuses, the text goes to a panel the user can copy from by hand instead.
|
|
284
|
+
*/
|
|
285
|
+
async function copyContext(el: HTMLElement, src: SourceLoc): Promise<boolean> {
|
|
286
|
+
let text: string;
|
|
287
|
+
let label: string;
|
|
288
|
+
try {
|
|
289
|
+
const ctx = await collectContext(el, src, projectRoot);
|
|
290
|
+
text = formatContext(ctx);
|
|
291
|
+
label = ctx.label;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
toast(`Could not gather context — ${err instanceof Error ? err.message : 'unknown'}`, 'err');
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
await navigator.clipboard.writeText(text);
|
|
298
|
+
toast(`Copied context for ${label} — ${sizeLabel(text)}`, 'ok');
|
|
299
|
+
return true;
|
|
300
|
+
} catch {
|
|
301
|
+
openCopyPanel(`${basename(src.file)}:${src.loc}`, text);
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const isEditMode = (): boolean => editMode;
|
|
307
|
+
/** Open the in-browser source-peek panel; its footer's "Open in editor" falls
|
|
308
|
+
* through to openSource. */
|
|
309
|
+
const openPeek = (src: SourceLoc): void => openPeekPanel(src, (s) => void openSource(s));
|
|
310
|
+
// Hover treats navigate-mode as "edit mode off": no outline, no tooltip.
|
|
311
|
+
// Its onTarget feeds page-hover into the element tree (page → tree sync).
|
|
312
|
+
const hover = initHover({
|
|
313
|
+
isEditMode: () => editMode && !navigating,
|
|
314
|
+
openSource: (src) => void openSource(src),
|
|
315
|
+
openPeek,
|
|
316
|
+
cssInspector: () => has('cssInspector'),
|
|
317
|
+
openRule: (file, selector) => void openRule(file, selector),
|
|
318
|
+
copyContext,
|
|
319
|
+
onTarget: (el) => tree.syncActive(el),
|
|
320
|
+
});
|
|
321
|
+
// The tree is created BEFORE the router so its capture-phase "click to deselect"
|
|
322
|
+
// listener runs ahead of the router's (which stopImmediatePropagation()s clicks
|
|
323
|
+
// on editable targets). `router` is referenced lazily in openEditor, so the
|
|
324
|
+
// forward reference is safe — it only fires on a row double-click, long after
|
|
325
|
+
// boot. Highlights reuse hover's outline + verdict pill (tree → page sync).
|
|
326
|
+
const tree = initTree({
|
|
327
|
+
isEditMode: () => editMode && !navigating,
|
|
328
|
+
highlight: (el) => hover.highlight(el),
|
|
329
|
+
clearHighlight,
|
|
330
|
+
openEditor: (el) => router.openElementAt(el),
|
|
331
|
+
openSource: (src) => void openSource(src),
|
|
332
|
+
// The ✕ and the edge tab toggle the panel themselves; record the choice so a
|
|
333
|
+
// save-triggered reload brings the tree back the way the user left it.
|
|
334
|
+
onToggle: (open) => {
|
|
335
|
+
rememberTree(open);
|
|
336
|
+
bar.refresh();
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
const router = initRouter({
|
|
340
|
+
isEditMode,
|
|
341
|
+
isNavigating: () => navigating,
|
|
342
|
+
openSource: (src) => void openSource(src),
|
|
343
|
+
openPeek,
|
|
344
|
+
});
|
|
345
|
+
// The admin bar is a view over everything above: it owns no editing state, it
|
|
346
|
+
// reads and drives it. Created last so its deps close over live references.
|
|
347
|
+
const bar = initAdminBar({
|
|
348
|
+
isEditMode,
|
|
349
|
+
enterEdit: () => setEditMode(true),
|
|
350
|
+
exitEdit: exitEditing,
|
|
351
|
+
// The tree's row hover only means anything in edit mode, so asking for the
|
|
352
|
+
// tree from a cold page turns edit mode on with it.
|
|
353
|
+
showTree: () => {
|
|
354
|
+
rememberTree(true);
|
|
355
|
+
if (!editMode) {
|
|
356
|
+
setEditMode(true); // opens the tree with it, now that it is wanted
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
tree.rebuild();
|
|
360
|
+
tree.show();
|
|
361
|
+
bar.refresh();
|
|
362
|
+
},
|
|
363
|
+
hideTree: () => {
|
|
364
|
+
rememberTree(false);
|
|
365
|
+
tree.hide();
|
|
366
|
+
bar.refresh();
|
|
367
|
+
},
|
|
368
|
+
isTreeOpen: () => tree.isOpen(),
|
|
369
|
+
// Both halves are live: the page must declare a backing entry, *and* the
|
|
370
|
+
// entry editor must be switched on — which the Settings drawer can change
|
|
371
|
+
// without a reload.
|
|
372
|
+
hasEntry: () => has('entryEditor') && pageSource() !== null,
|
|
373
|
+
openEntry: () => {
|
|
374
|
+
const file = pageSource();
|
|
375
|
+
if (file) void openEntryPanel(file);
|
|
376
|
+
},
|
|
377
|
+
openPageSource: () => void openPageSource(),
|
|
378
|
+
openCollections: () => openCollectionsPanel({ onClose: () => bar.refresh() }),
|
|
379
|
+
// Saving settings changes what the bar should show (the entry button, the
|
|
380
|
+
// page-source item, Collections itself), so the bar re-evaluates its specs
|
|
381
|
+
// once the drawer is gone.
|
|
382
|
+
openSettings: () => openSettingsPanel({ onClose: () => bar.refresh() }),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// After an HMR update: drop stale hover state, and re-snapshot source
|
|
386
|
+
// mappings from the freshly-rendered (re-annotated) DOM before the toolbar
|
|
387
|
+
// strips them again. (spec §4.2 / §7.4, adapted for attribute-stripping)
|
|
388
|
+
if (import.meta.hot) {
|
|
389
|
+
import.meta.hot.on('vite:afterUpdate', () => {
|
|
390
|
+
clearHighlight();
|
|
391
|
+
invalidateClassifications(); // the source changed — cached verdicts are stale
|
|
392
|
+
cacheSourceMappings();
|
|
393
|
+
// The DOM (and every element object) was replaced — rebuild from the fresh
|
|
394
|
+
// annotations, preserving collapse + selection by their stable paths.
|
|
395
|
+
if (editMode) tree.rebuild();
|
|
396
|
+
bar.refresh(); // a navigation may have gained or lost a content entry
|
|
397
|
+
// …and the answer itself can have changed: an entry renamed, or a route
|
|
398
|
+
// added. The resolve refreshes the bar again when it lands.
|
|
399
|
+
void resolvePageSource();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Boot
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
async function boot(): Promise<void> {
|
|
408
|
+
// Confirm the server side is alive before showing the bar. If the health
|
|
409
|
+
// check fails the overlay stays out of the way entirely.
|
|
410
|
+
const info = await api.health();
|
|
411
|
+
if (!info) return;
|
|
412
|
+
projectRoot = info.root ?? null; // older servers don't send it — paths stay absolute
|
|
413
|
+
// Every option-derived flag is read through features.ts rather than a local,
|
|
414
|
+
// so the media modal can see them without importing the composition root and
|
|
415
|
+
// so a Settings save updates them in place. (see features.ts)
|
|
416
|
+
setFeatures(info);
|
|
417
|
+
mount(
|
|
418
|
+
...hover.elements,
|
|
419
|
+
tree.selectionOutline,
|
|
420
|
+
tree.root,
|
|
421
|
+
tree.tab,
|
|
422
|
+
...bar.elements,
|
|
423
|
+
);
|
|
424
|
+
bar.refresh();
|
|
425
|
+
|
|
426
|
+
// Which entry backs this page, for a project that hasn't emitted the meta tag.
|
|
427
|
+
// Deliberately not awaited before the bar is drawn: the bar must appear at
|
|
428
|
+
// once, and the entry button is the only thing this can add to it. Every later
|
|
429
|
+
// resolve — an HMR update, or the notice switching a collection on — reaches
|
|
430
|
+
// the bar through the same subscription.
|
|
431
|
+
onPageSourceChange(() => bar.refresh());
|
|
432
|
+
void resolvePageSource();
|
|
433
|
+
|
|
434
|
+
// Restore edit mode — and whether the tree was open with it — across the
|
|
435
|
+
// full-page reload that follows every save.
|
|
436
|
+
try {
|
|
437
|
+
treeWanted = sessionStorage.getItem('astroDevEditTree') === '1';
|
|
438
|
+
if (sessionStorage.getItem('astroDevEditMode') === '1') setEditMode(true);
|
|
439
|
+
} catch {
|
|
440
|
+
// sessionStorage unavailable — start with edit mode off.
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Creating an entry reloads the page for the same reason, while the create
|
|
444
|
+
// is still waiting for the new route to answer. Pick that wait back up.
|
|
445
|
+
resumePendingNavigation();
|
|
446
|
+
|
|
447
|
+
// A schema write reloads the page (Astro resyncs its content layer), which
|
|
448
|
+
// would otherwise close the drawer the user was working in. Reopen it where
|
|
449
|
+
// they were.
|
|
450
|
+
const resumeCollection = takePendingCollection();
|
|
451
|
+
if (resumeCollection) {
|
|
452
|
+
openCollectionsPanel({
|
|
453
|
+
collection: resumeCollection,
|
|
454
|
+
onClose: () => {
|
|
455
|
+
clearPendingCollection();
|
|
456
|
+
bar.refresh();
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (document.readyState === 'loading') {
|
|
463
|
+
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
|
464
|
+
} else {
|
|
465
|
+
void boot();
|
|
466
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { EntryResolveRefusal } from '../shared/protocol.ts';
|
|
2
|
+
import * as api from './api.ts';
|
|
3
|
+
import { has } from './features.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The content file backing a detail page — where the page's real copy lives.
|
|
7
|
+
*
|
|
8
|
+
* Detail routes (e.g. `/area/<slug>/`) render a markdown/MDX entry through a
|
|
9
|
+
* template, so their dynamic text — the title, body prose, etc. — lives in a
|
|
10
|
+
* `.md`/`.mdx` file, not in the `.astro` the source loc points at. Knowing that
|
|
11
|
+
* file is what turns the refusal into an "Edit page content" jump and puts the
|
|
12
|
+
* entry drawer in the admin bar.
|
|
13
|
+
*
|
|
14
|
+
* **Two ways to know it, and the page's own declaration wins.** A layout may
|
|
15
|
+
* emit `<meta name="astro-dev-edit:page-source" content="src/content/…/x.mdx">`,
|
|
16
|
+
* which needs no server round trip and covers data sources the tool cannot walk.
|
|
17
|
+
* Otherwise the server resolves it from the URL — see {@link resolvePageSource}
|
|
18
|
+
* — for every collection whose page editing is switched on. The meta tag is
|
|
19
|
+
* checked first on every call, so a page that declares one behaves exactly as it
|
|
20
|
+
* always has.
|
|
21
|
+
*
|
|
22
|
+
* Its own module rather than a corner of editors/notice.ts: it's a fact about
|
|
23
|
+
* the page, read by four unrelated callers, and it must stay importable without
|
|
24
|
+
* dragging in the overlay's DOM-side modules.
|
|
25
|
+
*/
|
|
26
|
+
const META = 'astro-dev-edit:page-source';
|
|
27
|
+
|
|
28
|
+
/** The name this meta carried before the project was renamed in 0.7.0. */
|
|
29
|
+
const LEGACY_META = 'astro-text-edit:page-source';
|
|
30
|
+
|
|
31
|
+
let warnedLegacy = false;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A layout still emitting the pre-0.7 name gets one console line saying so.
|
|
35
|
+
*
|
|
36
|
+
* The old name is **not** accepted — there is no migration shim, and quietly
|
|
37
|
+
* honouring it would make the rename meaningless. But the failure it produces
|
|
38
|
+
* on its own is invisible: the entry button hides itself, every entry flow
|
|
39
|
+
* behind it is simply absent, and `/health` still reports the editor as on,
|
|
40
|
+
* because the server knows nothing about a tag only the client reads. That is
|
|
41
|
+
* a one-word fix behind an hour of looking, so it is worth a line.
|
|
42
|
+
*/
|
|
43
|
+
function warnLegacyMeta(): void {
|
|
44
|
+
if (warnedLegacy) return;
|
|
45
|
+
if (!document.querySelector(`meta[name="${LEGACY_META}"]`)) return;
|
|
46
|
+
warnedLegacy = true;
|
|
47
|
+
console.warn(
|
|
48
|
+
`[astro-dev-edit] This page declares <meta name="${LEGACY_META}">, the name used ` +
|
|
49
|
+
`before 0.7.0. Rename it to "${META}" — until then the entry editor stays hidden ` +
|
|
50
|
+
'on this page.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function metaSource(): string | null {
|
|
55
|
+
const meta = document.querySelector<HTMLMetaElement>(`meta[name="${META}"]`);
|
|
56
|
+
const content = meta?.content?.trim();
|
|
57
|
+
if (!content) {
|
|
58
|
+
warnLegacyMeta();
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return content;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What the server last said about this URL. Null until a resolve has landed. */
|
|
65
|
+
export interface PageEntryInfo {
|
|
66
|
+
/** The collection the page renders, when one was identified. Set on a
|
|
67
|
+
* `not-enabled` refusal too, which is what lets the notice name it. */
|
|
68
|
+
collection: string | null;
|
|
69
|
+
/** The entry that was found, editable or not. */
|
|
70
|
+
entryFile: string | null;
|
|
71
|
+
/** Whether `astro.config.mjs` owns the collection's page-editing switch. */
|
|
72
|
+
pageEditingLocked: boolean;
|
|
73
|
+
refusal: EntryResolveRefusal | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let resolved: string | null = null;
|
|
77
|
+
let info: PageEntryInfo | null = null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Told whenever a resolve lands.
|
|
81
|
+
*
|
|
82
|
+
* This module owns the cached answer, so it is the only thing that knows when
|
|
83
|
+
* the answer changed — and the admin bar's entry button is the thing that has
|
|
84
|
+
* to notice. A subscription here beats every caller of {@link resolvePageSource}
|
|
85
|
+
* remembering to refresh the bar afterwards: the refusal notice re-resolves
|
|
86
|
+
* after switching a collection on, and that path gets the refresh for free.
|
|
87
|
+
*/
|
|
88
|
+
const listeners = new Set<() => void>();
|
|
89
|
+
|
|
90
|
+
export function onPageSourceChange(fn: () => void): void {
|
|
91
|
+
listeners.add(fn);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The backing entry, or null.
|
|
96
|
+
*
|
|
97
|
+
* **Stays synchronous.** Its four callers — the admin bar's visibility
|
|
98
|
+
* predicate, the entry button's click, the refusal notice and the copied
|
|
99
|
+
* element context — all run inside code that cannot await, and the bar
|
|
100
|
+
* re-evaluates them on every `refresh()` anyway. So the async half writes into
|
|
101
|
+
* a cache and this reads it, rather than the callers changing shape.
|
|
102
|
+
*/
|
|
103
|
+
export function pageSource(): string | null {
|
|
104
|
+
return metaSource() ?? resolved;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What the last resolve found, for a caller that needs the refusal and not
|
|
108
|
+
* just the file — the notice's offer to switch a collection on. */
|
|
109
|
+
export function pageEntryInfo(): PageEntryInfo | null {
|
|
110
|
+
return info;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Ask the server which entry backs this URL, and cache the answer.
|
|
115
|
+
*
|
|
116
|
+
* Called at boot, after every HMR update, and after the refusal notice switches
|
|
117
|
+
* a collection on. Every listener is told when it lands, whichever of those it
|
|
118
|
+
* was.
|
|
119
|
+
*
|
|
120
|
+
* Skipped entirely when the page declares a meta tag (it would win regardless)
|
|
121
|
+
* or the entry editor is off. A failed request is a cleared cache, not a thrown
|
|
122
|
+
* error: not knowing the backing entry is the state this feature exists to
|
|
123
|
+
* improve on, and it is a state the overlay already handles everywhere.
|
|
124
|
+
*/
|
|
125
|
+
export async function resolvePageSource(): Promise<void> {
|
|
126
|
+
resolved = null;
|
|
127
|
+
info = null;
|
|
128
|
+
if (metaSource() === null && has('entryEditor')) {
|
|
129
|
+
try {
|
|
130
|
+
const res = await api.resolveEntry({ pathname: location.pathname });
|
|
131
|
+
resolved = res.file;
|
|
132
|
+
info = {
|
|
133
|
+
collection: res.collection,
|
|
134
|
+
entryFile: res.entryFile,
|
|
135
|
+
pageEditingLocked: res.pageEditingLocked,
|
|
136
|
+
refusal: res.refusal,
|
|
137
|
+
};
|
|
138
|
+
} catch {
|
|
139
|
+
/* no answer is the same as no meta tag: the entry surfaces stay hidden */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const fn of listeners) fn();
|
|
143
|
+
}
|