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,45 @@
1
+ import type { SourceLoc } from '../shared/protocol.ts';
2
+
3
+ /**
4
+ * The element-tree's pure nesting model, kept in its own DOM-free module (like
5
+ * server/inspect-locate.ts) so it unit-tests without a browser environment.
6
+ * tree.ts owns everything that touches the live DOM.
7
+ */
8
+
9
+ export interface TreeNode {
10
+ el: HTMLElement;
11
+ source: SourceLoc;
12
+ children: TreeNode[];
13
+ }
14
+
15
+ /**
16
+ * Nest a flat, document-ordered list of annotated elements by DOM ancestry.
17
+ * Each element's parent is its nearest ancestor that is ALSO in the set; an
18
+ * element with no such ancestor is a root. `sourceOf`/`parentOf` are injected
19
+ * so this has no live-DOM dependency and is testable in isolation.
20
+ *
21
+ * Keyed on element objects, never on the source loc — a loc is not unique
22
+ * (elements in a `.map()` loop share one), so loop siblings are distinct nodes.
23
+ */
24
+ export function buildTreeModel(
25
+ elements: HTMLElement[],
26
+ sourceOf: (el: HTMLElement) => SourceLoc | undefined,
27
+ parentOf: (el: HTMLElement) => HTMLElement | null,
28
+ ): TreeNode[] {
29
+ const nodeFor = new Map<HTMLElement, TreeNode>();
30
+ for (const el of elements) {
31
+ const source = sourceOf(el);
32
+ if (source) nodeFor.set(el, { el, source, children: [] });
33
+ }
34
+ const roots: TreeNode[] = [];
35
+ for (const el of elements) {
36
+ const node = nodeFor.get(el);
37
+ if (!node) continue;
38
+ let anc = parentOf(el);
39
+ while (anc && !nodeFor.has(anc)) anc = parentOf(anc);
40
+ const parent = anc ? nodeFor.get(anc) : undefined;
41
+ if (parent) parent.children.push(node);
42
+ else roots.push(node);
43
+ }
44
+ return roots;
45
+ }
@@ -0,0 +1,366 @@
1
+ import type { SourceLoc } from '../shared/protocol.ts';
2
+ import { icon } from './icons.ts';
3
+ import { isOwnUi } from './shadow.ts';
4
+ import { annotatedElements, pathFor, sourceFor } from './source-map.ts';
5
+ import { type TreeNode, buildTreeModel } from './tree-model.ts';
6
+ import { basename, isolateScroll, onChromeInset, outlineRect, styled } from './ui.ts';
7
+
8
+ /**
9
+ * Element-tree panel: a left-docked, non-modal outline of the page's
10
+ * source-annotated elements, two-way highlight-linked to the page.
11
+ *
12
+ * - tree → page: hovering a row drives the existing hover outline + verdict
13
+ * pill (deps.highlight), so a row shows exactly what a page hover would;
14
+ * clicking a row LOCKS a persistent selection (this panel's own outline) and
15
+ * scrolls the element into view; double-click opens the editor.
16
+ * - page → tree: the composition root feeds hover's onTarget into syncActive,
17
+ * which mirrors the current page-hover onto a distinct, transient
18
+ * "hover-active" row style — never disturbing the locked selection.
19
+ *
20
+ * Deliberately modeled on hover.ts, NOT the drawer: it never claims the single
21
+ * state.ts interaction slot and uses no backdrop, so it coexists with editing.
22
+ * Every node is built through ui.ts::styled (router-exempt + atx-* theming), and
23
+ * the scroll body goes through isolateScroll like every other overlay surface.
24
+ *
25
+ * The tree is derived from the live DOM (annotatedElements), keyed on element
26
+ * objects — a source loc is NOT unique (elements in a `.map()` loop share one).
27
+ * Collapse and selection persist across HMR by pathFor(), since the element
28
+ * objects themselves are replaced on every save-triggered re-render.
29
+ *
30
+ * The pure nesting model lives in tree-model.ts (DOM-free, unit-tested).
31
+ */
32
+
33
+ // --- View --------------------------------------------------------------------
34
+
35
+ export interface TreeDeps {
36
+ isEditMode(): boolean;
37
+ /** Drive the hover outline + verdict pill for a row (tree → page). */
38
+ highlight(el: HTMLElement): void;
39
+ /** Clear the hover outline + pill. */
40
+ clearHighlight(): void;
41
+ /** Open the editor for an element (row double-click). */
42
+ openEditor(el: HTMLElement): void;
43
+ /** Jump the user's editor straight to a source location (row loc click). */
44
+ openSource(src: SourceLoc): void;
45
+ /** The panel showed or hid itself (its ✕, or the restore tab), so the admin
46
+ * bar's Elements button can follow. Not called for show()/hide() driven from
47
+ * outside — the caller already knows. */
48
+ onToggle?(open: boolean): void;
49
+ }
50
+
51
+ export interface TreeHandle {
52
+ /** The panel root — appended to <body> at boot. */
53
+ root: HTMLElement;
54
+ /** The edge tab that brings a closed panel back — appended to <body> at boot. */
55
+ tab: HTMLElement;
56
+ /** The locked-selection outline — appended to <body> at boot. */
57
+ selectionOutline: HTMLElement;
58
+ /** Re-enumerate the DOM and rebuild rows (boot, edit-on, after HMR). */
59
+ rebuild(): void;
60
+ show(): void;
61
+ hide(): void;
62
+ isOpen(): boolean;
63
+ /** Mirror the current page-hover element onto its row (page → tree). */
64
+ syncActive(el: HTMLElement | null): void;
65
+ clearSelection(): void;
66
+ hasSelection(): boolean;
67
+ }
68
+
69
+
70
+ export function initTree(deps: TreeDeps): TreeHandle {
71
+ // Panel shell: fixed to the left edge, full height. Below modal panels and
72
+ // drawers (Z_MODAL+5/6) so an open CMS drawer overlays it, above the hover
73
+ // pill so rows read clearly. Non-modal — no backdrop, never touches state.ts.
74
+ const root = styled('div', 'atx-tree', undefined, 'atx-tree');
75
+
76
+ const bar = styled('div', 'atx-tree-title');
77
+ const barText = styled('span', 'atx-tree-title-text');
78
+ barText.textContent = 'Elements';
79
+ const closeBtn = styled('button', 'atx-tree-close');
80
+ closeBtn.type = 'button';
81
+ closeBtn.append(icon('x', 16));
82
+ closeBtn.title = 'Hide the element tree';
83
+ closeBtn.addEventListener('click', () => {
84
+ hide();
85
+ deps.onToggle?.(false);
86
+ });
87
+ bar.append(barText, closeBtn);
88
+
89
+ // What the panel leaves behind while edit mode is still on: a tab on the left
90
+ // edge that brings it back, so closing the tree is never a one-way door (the
91
+ // bar's Elements button does the same job from the other end).
92
+ const tab = styled('button', 'atx-tree-tab', undefined, 'atx-tree-tab');
93
+ tab.type = 'button';
94
+ tab.title = 'Show the element tree';
95
+ tab.append(icon('sidebar', 16));
96
+ tab.addEventListener('click', () => {
97
+ show();
98
+ deps.onToggle?.(true);
99
+ });
100
+
101
+ const body = styled('div', 'atx-tree-body');
102
+ isolateScroll(body);
103
+
104
+ root.append(bar, body);
105
+
106
+ // Keep clear of the admin bar, whichever edge it is docked to. Fires once on
107
+ // subscribe, so the panel is correct however the two modules boot.
108
+ onChromeInset(({ top, bottom }) => {
109
+ root.style.top = `${top + 5}px`;
110
+ root.style.bottom = `${bottom + 5}px`;
111
+ });
112
+
113
+ // The locked-selection outline — this panel's own, distinct from hover's
114
+ // transient one: solid + glow, no fill, no transition (tracks scroll crisply).
115
+ const selectionOutline = styled('div', 'atx-tree-selection');
116
+
117
+ // --- State ---------------------------------------------------------------
118
+
119
+ let model: TreeNode[] = [];
120
+ const rowFor = new Map<HTMLElement, HTMLElement>();
121
+ const collapsed = new Set<string>(); // element paths that are collapsed
122
+ let selectedEl: HTMLElement | null = null;
123
+ let activeEl: HTMLElement | null = null;
124
+ let selectedPath: string | null = null; // to re-resolve selection across HMR
125
+
126
+ // --- Row styling ---------------------------------------------------------
127
+
128
+ function paintRow(el: HTMLElement | null): void {
129
+ if (!el) return;
130
+ const row = rowFor.get(el);
131
+ if (!row) return;
132
+ const selected = el === selectedEl;
133
+ const active = el === activeEl;
134
+ // Selected wins over active, which is what makes the dashed active ring
135
+ // disappear when the same row is both — see .atx-tree-row in styles.ts.
136
+ if (selected) row.dataset.state = 'selected';
137
+ else if (active) row.dataset.state = 'active';
138
+ else delete row.dataset.state;
139
+ }
140
+
141
+ // --- Selection (locked) --------------------------------------------------
142
+
143
+ function positionSelection(): void {
144
+ if (!selectedEl) return;
145
+ if (!selectedEl.isConnected) {
146
+ clearSelection();
147
+ return;
148
+ }
149
+ const r = selectedEl.getBoundingClientRect();
150
+ selectionOutline.toggleAttribute('data-on', true);
151
+ Object.assign(selectionOutline.style, outlineRect(r) as Partial<CSSStyleDeclaration>);
152
+ }
153
+
154
+ let repositionScheduled = false;
155
+ function onReposition(): void {
156
+ if (repositionScheduled) return;
157
+ repositionScheduled = true;
158
+ requestAnimationFrame(() => {
159
+ repositionScheduled = false;
160
+ positionSelection();
161
+ });
162
+ }
163
+
164
+ function select(el: HTMLElement): void {
165
+ const prev = selectedEl;
166
+ selectedEl = el;
167
+ selectedPath = pathFor(el);
168
+ if (prev) paintRow(prev);
169
+ paintRow(el);
170
+ positionSelection();
171
+ // Scroll the element into view. This scrolls the page (firing hover's own
172
+ // scroll→clearHighlight), and our reposition listener keeps the locked
173
+ // outline glued to the element as it moves.
174
+ el.scrollIntoView({ block: 'center', inline: 'nearest' });
175
+ window.addEventListener('scroll', onReposition, { passive: true });
176
+ window.addEventListener('resize', onReposition, { passive: true });
177
+ }
178
+
179
+ function clearSelection(): void {
180
+ const prev = selectedEl;
181
+ selectedEl = null;
182
+ selectedPath = null;
183
+ selectionOutline.toggleAttribute('data-on', false);
184
+ window.removeEventListener('scroll', onReposition);
185
+ window.removeEventListener('resize', onReposition);
186
+ if (prev) paintRow(prev);
187
+ }
188
+
189
+ function hasSelection(): boolean {
190
+ return selectedEl !== null;
191
+ }
192
+
193
+ // A click anywhere on the page (not on our own UI) clears the locked
194
+ // selection — "click elsewhere to deselect". Registered in capture phase and
195
+ // BEFORE the router's own click listener (initTree runs before initRouter) so
196
+ // the router's stopImmediatePropagation on editable targets can't pre-empt it.
197
+ document.addEventListener(
198
+ 'click',
199
+ (e) => {
200
+ if (!deps.isEditMode() || !selectedEl) return;
201
+ if (isOwnUi(e)) return; // clicks on the tree / pills / panels don't deselect
202
+ clearSelection();
203
+ },
204
+ true,
205
+ );
206
+
207
+ // --- page → tree ---------------------------------------------------------
208
+
209
+ function syncActive(el: HTMLElement | null): void {
210
+ if (el === activeEl) return;
211
+ const prev = activeEl;
212
+ activeEl = el && rowFor.has(el) ? el : null;
213
+ if (prev) paintRow(prev);
214
+ if (activeEl) {
215
+ paintRow(activeEl);
216
+ scrollRowIntoView(rowFor.get(activeEl)!);
217
+ }
218
+ }
219
+
220
+ /** Scroll the tree body just enough to reveal a row (never the page). */
221
+ function scrollRowIntoView(row: HTMLElement): void {
222
+ const rowRect = row.getBoundingClientRect();
223
+ const boxRect = body.getBoundingClientRect();
224
+ if (rowRect.top < boxRect.top) {
225
+ body.scrollTop -= boxRect.top - rowRect.top + 8;
226
+ } else if (rowRect.bottom > boxRect.bottom) {
227
+ body.scrollTop += rowRect.bottom - boxRect.bottom + 8;
228
+ }
229
+ }
230
+
231
+ // --- Rendering -----------------------------------------------------------
232
+
233
+ function makeRow(node: TreeNode, depth: number): HTMLElement {
234
+ const { el, source } = node;
235
+ const row = styled('div', 'atx-tree-row', {
236
+ // The indent is the row's depth, so this one is genuinely per-instance.
237
+ paddingLeft: `${10 + depth * 14}px`,
238
+ });
239
+
240
+ const hasChildren = node.children.length > 0;
241
+ const path = pathFor(el);
242
+ const chevron = styled('span', 'atx-tree-chevron');
243
+ // A leaf is not clickable, so it does not offer a pointer.
244
+ chevron.toggleAttribute('data-leaf', !hasChildren);
245
+ // A leaf's slot stays empty rather than carrying a mark of its own: the
246
+ // chevron's fixed width is what keeps the tags column-aligned, and a dot
247
+ // there read as a list bullet in front of every row that had no children.
248
+ if (hasChildren) {
249
+ chevron.append(icon(collapsed.has(path) ? 'chevronRight' : 'chevronDown', 12));
250
+ }
251
+ if (hasChildren) {
252
+ chevron.addEventListener('click', (e) => {
253
+ e.stopPropagation();
254
+ if (collapsed.has(path)) collapsed.delete(path);
255
+ else collapsed.add(path);
256
+ renderRows();
257
+ });
258
+ }
259
+
260
+ const tag = styled('span', 'atx-tree-tag');
261
+ tag.textContent = `<${el.tagName.toLowerCase()}>`;
262
+
263
+ row.append(chevron, tag);
264
+
265
+ // A short text preview for leaf text elements aids scanning.
266
+ if (!hasChildren) {
267
+ const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
268
+ if (text) {
269
+ const preview = styled('span', 'atx-tree-preview');
270
+ preview.textContent = text.length > 24 ? `${text.slice(0, 24)}…` : text;
271
+ row.append(preview);
272
+ }
273
+ }
274
+
275
+ // The loc doubles as an editor jump: clicking it opens the file at this line
276
+ // in the user's editor (the same /open the hover pill's "open ↗" uses), so it
277
+ // stops the click from also selecting the row.
278
+ const loc = styled('span', 'atx-tree-loc');
279
+ loc.textContent = source.loc || '?';
280
+ loc.title = `Open ${basename(source.file)}:${source.loc} in your editor`;
281
+ loc.addEventListener('click', (e) => {
282
+ e.stopPropagation();
283
+ deps.openSource(source);
284
+ });
285
+ row.append(loc);
286
+
287
+ row.addEventListener('mouseenter', () => {
288
+ if (deps.isEditMode()) deps.highlight(el);
289
+ });
290
+ row.addEventListener('mouseleave', () => deps.clearHighlight());
291
+ row.addEventListener('click', () => select(el));
292
+ row.addEventListener('dblclick', () => deps.openEditor(el));
293
+
294
+ rowFor.set(el, row);
295
+ paintRow(el);
296
+ return row;
297
+ }
298
+
299
+ function renderNodes(nodes: TreeNode[], depth: number, into: HTMLElement): void {
300
+ for (const node of nodes) {
301
+ into.append(makeRow(node, depth));
302
+ if (node.children.length && !collapsed.has(pathFor(node.el))) {
303
+ renderNodes(node.children, depth + 1, into);
304
+ }
305
+ }
306
+ }
307
+
308
+ function renderRows(): void {
309
+ rowFor.clear();
310
+ body.replaceChildren();
311
+ if (model.length === 0) {
312
+ const empty = styled('div', 'atx-tree-empty');
313
+ empty.textContent = 'No source-annotated elements on this page.';
314
+ body.append(empty);
315
+ return;
316
+ }
317
+ renderNodes(model, 0, body);
318
+ }
319
+
320
+ function rebuild(): void {
321
+ model = buildTreeModel(annotatedElements(), sourceFor, (el) => el.parentElement);
322
+ renderRows();
323
+ // Re-resolve the locked selection onto the fresh DOM by its stable path.
324
+ if (selectedPath) {
325
+ const match = [...rowFor.keys()].find((el) => pathFor(el) === selectedPath);
326
+ if (match) {
327
+ selectedEl = match;
328
+ paintRow(match);
329
+ positionSelection();
330
+ } else {
331
+ clearSelection();
332
+ }
333
+ }
334
+ }
335
+
336
+ function show(): void {
337
+ root.toggleAttribute('data-on', true);
338
+ tab.toggleAttribute('data-on', false);
339
+ }
340
+
341
+ function hide(): void {
342
+ root.toggleAttribute('data-on', false);
343
+ // The tab only makes sense while editing — outside edit mode the tree has
344
+ // nothing live to point at, and the bar's Elements button reopens both.
345
+ tab.toggleAttribute('data-on', deps.isEditMode());
346
+ syncActive(null);
347
+ clearSelection();
348
+ }
349
+
350
+ function isOpen(): boolean {
351
+ return root.hasAttribute('data-on');
352
+ }
353
+
354
+ return {
355
+ root,
356
+ tab,
357
+ selectionOutline,
358
+ rebuild,
359
+ show,
360
+ hide,
361
+ isOpen,
362
+ syncActive,
363
+ clearSelection,
364
+ hasSelection,
365
+ };
366
+ }