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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. 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
+ }