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,125 @@
1
+ import type { SourceLoc } from '../../shared/protocol.ts';
2
+ import * as api from '../api.ts';
3
+ import { clearHighlight } from '../hover.ts';
4
+ import * as state from '../state.ts';
5
+ import { basename, lockElement, styled, toast } from '../ui.ts';
6
+ import { TAGS, type TagSpec, tagInsertion } from './markup-insert.ts';
7
+ import { openSourcePopup } from './source-popup.ts';
8
+
9
+ /**
10
+ * Markup popup: for text that carries inline formatting (`<br>`, `<strong>`,
11
+ * a link). Inline contenteditable can't serve these — the literal-text path
12
+ * escapes `<`, which would turn the tags into visible punctuation — so the
13
+ * element's *source* is edited as raw text instead, in a deliberately
14
+ * different affordance from inline editing.
15
+ *
16
+ * The value shown is the source region /classify returned, not the DOM's
17
+ * innerHTML: only the source knows how entities and attribute quotes were
18
+ * spelled, and sending it straight back as the apply op's `original` keeps
19
+ * verify-then-patch comparing like with like. The server re-vets every tag and
20
+ * attribute before writing — this panel is an editor, not the gate.
21
+ */
22
+
23
+ /** The allowed tags are on screen anyway — the server refuses anything outside
24
+ * the list — so they double as the way to insert them. */
25
+ const HINT = 'Insert (wraps the selection):';
26
+
27
+ /** Replace a range of the textarea, preferring execCommand so the edit joins
28
+ * the browser's own undo stack; setRangeText is the fallback. */
29
+ function replaceRange(input: HTMLTextAreaElement, start: number, end: number, text: string): void {
30
+ input.focus();
31
+ input.setSelectionRange(start, end);
32
+ let inserted = false;
33
+ try {
34
+ inserted = document.execCommand('insertText', false, text);
35
+ } catch {
36
+ inserted = false;
37
+ }
38
+ if (!inserted) input.setRangeText(text, start, end, 'end');
39
+ }
40
+
41
+ /** Insert a tag at the caret, wrapping the selection when there is one. */
42
+ function insertTag(input: HTMLTextAreaElement, spec: TagSpec): void {
43
+ const start = input.selectionStart;
44
+ const end = input.selectionEnd;
45
+ const { text, caretFrom, caretTo } = tagInsertion(input.value.slice(start, end), spec);
46
+ replaceRange(input, start, end, text);
47
+ input.setSelectionRange(start + caretFrom, start + caretTo);
48
+ }
49
+
50
+ /** The palette row: one button per allowed tag. */
51
+ function buildPalette(input: HTMLTextAreaElement, markDirty: () => void): HTMLElement {
52
+ const tools = styled('div', 'atx-markup-tags');
53
+ const hint = styled('span', 'atx-markup-hint');
54
+ hint.textContent = HINT;
55
+ tools.append(hint);
56
+
57
+ for (const spec of TAGS) {
58
+ const btn = styled('button', 'atx-markup-tag');
59
+ btn.type = 'button';
60
+ btn.textContent = `<${spec.tag}>`;
61
+ btn.title = spec.isVoid
62
+ ? `Insert <${spec.tag}>`
63
+ : `Wrap the selection in <${spec.tag}>…</${spec.tag}>`;
64
+ // Buttons take focus on mousedown, which would collapse the textarea's
65
+ // selection before the click lands — and the selection is what we wrap.
66
+ btn.addEventListener('mousedown', (e) => e.preventDefault());
67
+ btn.addEventListener('click', () => {
68
+ insertTag(input, spec);
69
+ markDirty();
70
+ });
71
+ tools.append(btn);
72
+ }
73
+ return tools;
74
+ }
75
+
76
+ export function beginMarkupEdit(
77
+ el: HTMLElement,
78
+ src: SourceLoc,
79
+ html: string,
80
+ openSource: (src: SourceLoc) => void,
81
+ ): void {
82
+ clearHighlight();
83
+ openSourcePopup({
84
+ title: `Markup · ${basename(src.file)}:${src.loc}`,
85
+ label: `Source of <${el.tagName.toLowerCase()}>`,
86
+ value: html,
87
+ mono: true,
88
+ tools: buildPalette,
89
+ openSource: () => openSource(src),
90
+ save: (value) => commitMarkupEdit(el, src, html, value),
91
+ });
92
+ }
93
+
94
+ /** Writes the edit. Resolves to null on success, or the refusal message —
95
+ * which the popup shows in the panel it deliberately left open. */
96
+ async function commitMarkupEdit(
97
+ el: HTMLElement,
98
+ src: SourceLoc,
99
+ original: string,
100
+ newHtml: string,
101
+ ): Promise<string | null> {
102
+ const busy = state.begin({ kind: 'busy' });
103
+ const release = lockElement(el);
104
+ state.setSavePhase('saving');
105
+ try {
106
+ await api.apply({
107
+ file: src.file,
108
+ loc: src.loc,
109
+ tag: el.tagName.toLowerCase(),
110
+ ops: [{ targetType: 'markup', original, newText: newHtml }],
111
+ });
112
+ state.setSavePhase('saved');
113
+ toast(`Saved — ${basename(src.file)}:${src.loc}`, 'ok');
114
+ // The file is written; Astro HMR reloads the page from disk. Nothing is
115
+ // patched into the live DOM here — a markup change can restructure the
116
+ // element's children, and HMR is the one source of truth for that.
117
+ return null;
118
+ } catch (err) {
119
+ state.setSavePhase('error');
120
+ return err instanceof Error ? err.message : 'The edit could not be saved.';
121
+ } finally {
122
+ release();
123
+ state.releaseIf(busy);
124
+ }
125
+ }
@@ -0,0 +1,326 @@
1
+ import { isolateScroll, setFreshSrc, styled } from '../ui.ts';
2
+ import { icon } from '../icons.ts';
3
+
4
+ /**
5
+ * The tile grid shared by both of the media modal's panes. One builder, two
6
+ * caption modes: a filename for a project asset, a photographer credit for an
7
+ * Unsplash photo.
8
+ *
9
+ * Tile anatomy matters and is easy to get wrong. `atx-media-tile` wraps a
10
+ * `<button>` (the pick target) and the caption as **siblings** — the credit
11
+ * caption contains `<a>` elements, and an anchor nested inside a button is
12
+ * invalid HTML whose click the button would swallow. So a credit link opens the
13
+ * photographer's profile without also selecting the photo.
14
+ *
15
+ * `repeat(auto-fill, minmax(132px, 1fr))` means the column count is correct at
16
+ * any modal width with no media queries and no JS measurement.
17
+ */
18
+
19
+ export type TileCaption =
20
+ /** A project asset: its filename, full path on hover. */
21
+ | { kind: 'name'; text: string; title?: string }
22
+ /** An Unsplash photo: the attribution the API guidelines require. Both URLs
23
+ * arrive from the server already carrying the utm params. */
24
+ | { kind: 'credit'; photographer: string; photographerUrl: string; pageUrl: string };
25
+
26
+ export interface GridTile {
27
+ /** Stable identity — a web path for project assets, the photo id for Unsplash. */
28
+ key: string;
29
+ /** What the `<img>` loads. */
30
+ thumbUrl: string;
31
+ /** Written moments ago, so it may still be inside Vite's brief 404 window
32
+ * and needs the retrying loader (ui.ts::setFreshSrc). */
33
+ fresh?: boolean;
34
+ /** Average colour, painted behind the thumb so the grid doesn't flash grey. */
35
+ color?: string;
36
+ /** Accessible name for the pick button. */
37
+ label: string;
38
+ caption: TileCaption;
39
+ /** Marks the value the field already holds. */
40
+ current?: boolean;
41
+ /**
42
+ * Why this tile cannot be picked *here*. Shown on the tile and as its title,
43
+ * and the pick button goes inert.
44
+ *
45
+ * Disabled rather than hidden on purpose: an asset dir listing that quietly
46
+ * drops half its files reads as "you have no images" when the real answer is
47
+ * "not this one, and here is why" — the stance the collection designer takes
48
+ * with a disabled field type. (issue #9)
49
+ */
50
+ disabledReason?: string;
51
+ /** The long form of that reason, for the tile's title. The band on the tile
52
+ * is a few words over a thumbnail; the whole sentence belongs on hover. */
53
+ disabledTitle?: string;
54
+ }
55
+
56
+ export interface MediaGridHandle {
57
+ /** The scroller, ready to append. */
58
+ el: HTMLElement;
59
+ setTiles(tiles: GridTile[]): void;
60
+ /** Placeholder tiles during a load — sized like real ones, so nothing
61
+ * reflows when the results land. */
62
+ showSkeletons(count?: number): void;
63
+ /** Replace the grid with a message, optionally offering a retry. */
64
+ showMessage(text: string, retry?: () => void): void;
65
+ /** Currently staged key, or null. */
66
+ selected(): string | null;
67
+ select(key: string | null): void;
68
+ /** Dim one tile and make it inert — an import in flight. Deliberately
69
+ * per-tile: taking a global busy lock would break the modal's own Escape
70
+ * and backdrop for the duration of a multi-second download. */
71
+ setTileBusy(key: string, busy: boolean): void;
72
+ /** Append below the grid (the Load more button lives here). */
73
+ footer: HTMLElement;
74
+ }
75
+
76
+ export interface MediaGridOptions {
77
+ /** A click on a tile — stages it. */
78
+ onSelect(key: string | null): void;
79
+ /** Double-click or Enter — the "use this one" shortcut. */
80
+ onCommit(key: string): void;
81
+ /** Shown when `setTiles` receives nothing. */
82
+ emptyText?: string;
83
+ }
84
+
85
+ /** How many placeholder tiles a loading grid shows. The tile's own minimum
86
+ * width lives in styles.ts, where the grid template that uses it is. */
87
+ const SKELETON_COUNT = 6;
88
+
89
+ export function buildMediaGrid(opts: MediaGridOptions): MediaGridHandle {
90
+ const el = styled('div', 'atx-media-pane');
91
+ isolateScroll(el);
92
+
93
+ const grid = styled('div', 'atx-media-grid');
94
+ const footer = styled('div', 'atx-media-more');
95
+ el.append(grid, footer);
96
+
97
+ /** key → its pick button, for selection and busy state. */
98
+ const buttons = new Map<string, HTMLButtonElement>();
99
+ let selectedKey: string | null = null;
100
+
101
+ const paint = (key: string, on: boolean): void => {
102
+ const btn = buttons.get(key);
103
+ if (!btn) return;
104
+ // The ring and the tick are the same state, so one flag drives both.
105
+ btn.toggleAttribute('data-selected', on);
106
+ };
107
+
108
+ const select = (key: string | null): void => {
109
+ if (selectedKey === key) return;
110
+ if (selectedKey) paint(selectedKey, false);
111
+ selectedKey = key;
112
+ if (key) paint(key, true);
113
+ opts.onSelect(key);
114
+ };
115
+
116
+ /** Arrow keys walk the grid. The column count is read from the laid-out
117
+ * tiles rather than assumed, so it stays right at any modal width. A
118
+ * disabled tile still counts, or the geometry the count describes would be
119
+ * the wrong grid. */
120
+ const columns = (): number => {
121
+ const tiles = [...buttons.values()];
122
+ if (tiles.length < 2) return 1;
123
+ const top = tiles[0].getBoundingClientRect().top;
124
+ const inRow = tiles.filter((b) => Math.abs(b.getBoundingClientRect().top - top) < 2);
125
+ return Math.max(1, inRow.length);
126
+ };
127
+
128
+ const moveFocus = (from: HTMLButtonElement, delta: number): void => {
129
+ const tiles = [...buttons.values()];
130
+ // Step past anything inert — a disabled tile cannot take focus, so landing
131
+ // on one would strand the keyboard where the mouse can still go.
132
+ const step = delta > 0 ? 1 : -1;
133
+ let at = tiles.indexOf(from) + delta;
134
+ while (tiles[at]?.disabled) at += step;
135
+ const next = tiles[at];
136
+ if (next) next.focus();
137
+ };
138
+
139
+ const clearGrid = (): void => {
140
+ grid.textContent = '';
141
+ buttons.clear();
142
+ // The staged key is gone with its tile; tell the caller so the footer's
143
+ // "Use image" button can't act on something no longer on screen.
144
+ if (selectedKey !== null) {
145
+ selectedKey = null;
146
+ opts.onSelect(null);
147
+ }
148
+ };
149
+
150
+ const buildTile = (tile: GridTile): HTMLElement => {
151
+ const wrap = styled('div', 'atx-media-tile');
152
+
153
+ const pick = styled('button', 'atx-media-pick');
154
+ // An Unsplash tile carries the photo's own average colour, so the tile is
155
+ // never a grey hole while the thumbnail loads. Everything else falls back
156
+ // to the checkerboard the class already paints.
157
+ if (tile.color) pick.style.background = tile.color;
158
+ pick.type = 'button';
159
+ pick.setAttribute('aria-label', tile.label);
160
+ pick.title = tile.label;
161
+
162
+ const img = styled('img', 'atx-media-thumb');
163
+ img.alt = '';
164
+ img.loading = 'lazy';
165
+ img.decoding = 'async';
166
+ // A CSP-blocked, offline or deleted image leaves the tile usable — the
167
+ // caption still reads and the photo can still be picked.
168
+ img.addEventListener('error', () => {
169
+ img.toggleAttribute('data-hidden', true);
170
+ pick.style.background = ''; // back to the class's checkerboard
171
+ fallback.toggleAttribute('data-on', true);
172
+ });
173
+ // A retry that finally succeeds must undo that fallback.
174
+ img.addEventListener('load', () => {
175
+ img.toggleAttribute('data-hidden', false);
176
+ fallback.toggleAttribute('data-on', false);
177
+ });
178
+ const fallback = styled('span', 'atx-media-fallback');
179
+ fallback.append(icon('image', 20));
180
+ pick.append(img, fallback);
181
+ // Assigned last, so both handlers above are attached before loading starts.
182
+ if (tile.fresh) setFreshSrc(img, tile.thumbUrl);
183
+ else img.src = tile.thumbUrl;
184
+
185
+ // Selection badge, hidden until staged.
186
+ const check = styled('span', 'atx-media-check');
187
+ check.append(icon('check', 14));
188
+ pick.append(check);
189
+
190
+ if (tile.current) {
191
+ const chip = styled('span', 'atx-media-current');
192
+ chip.textContent = 'Current';
193
+ pick.append(chip);
194
+ }
195
+
196
+ if (tile.disabledReason) {
197
+ wrap.toggleAttribute('data-off', true);
198
+ pick.disabled = true;
199
+ // The reason replaces the label as the title: "why can't I click this"
200
+ // is the only question a dimmed tile raises.
201
+ pick.title = tile.disabledTitle ?? tile.disabledReason;
202
+ pick.setAttribute(
203
+ 'aria-label',
204
+ `${tile.label} — ${tile.disabledTitle ?? tile.disabledReason}`,
205
+ );
206
+ const note = styled('span', 'atx-media-reason');
207
+ note.textContent = tile.disabledReason;
208
+ pick.append(note);
209
+ }
210
+
211
+ pick.addEventListener('click', () => select(tile.key));
212
+ pick.addEventListener('dblclick', () => {
213
+ select(tile.key);
214
+ opts.onCommit(tile.key);
215
+ });
216
+ pick.addEventListener('keydown', (e) => {
217
+ const cols = columns();
218
+ const moves: Record<string, number> = {
219
+ ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols,
220
+ };
221
+ if (e.key in moves) {
222
+ e.preventDefault();
223
+ moveFocus(pick, moves[e.key]);
224
+ return;
225
+ }
226
+ if (e.key === 'Enter') {
227
+ e.preventDefault();
228
+ select(tile.key);
229
+ opts.onCommit(tile.key);
230
+ }
231
+ });
232
+ // Focusing a tile stages it, so keyboard and mouse agree on what "current
233
+ // choice" means.
234
+ pick.addEventListener('focus', () => select(tile.key));
235
+
236
+ wrap.append(pick, buildCaption(tile.caption));
237
+ buttons.set(tile.key, pick);
238
+ return wrap;
239
+ };
240
+
241
+ return {
242
+ el,
243
+ footer,
244
+
245
+ setTiles(tiles) {
246
+ clearGrid();
247
+ if (!tiles.length) {
248
+ grid.append(message(opts.emptyText ?? 'Nothing to show.'));
249
+ return;
250
+ }
251
+ for (const tile of tiles) grid.append(buildTile(tile));
252
+ },
253
+
254
+ showSkeletons(count = SKELETON_COUNT) {
255
+ clearGrid();
256
+ for (let i = 0; i < count; i++) {
257
+ const wrap = styled('div', 'atx-media-tile atx-media-skeleton');
258
+ const box = styled('div', 'atx-media-skeleton-thumb');
259
+ const bar = styled('div', 'atx-media-skeleton-cap');
260
+ wrap.append(box, bar);
261
+ grid.append(wrap);
262
+ }
263
+ },
264
+
265
+ showMessage(text, retry) {
266
+ clearGrid();
267
+ grid.append(message(text, retry));
268
+ },
269
+
270
+ selected: () => selectedKey,
271
+ select,
272
+
273
+ setTileBusy(key, busy) {
274
+ const btn = buttons.get(key);
275
+ if (!btn) return;
276
+ // aria-busy is both the accessible state and the style hook; the tile
277
+ // dims, stops taking clicks and says so with its cursor.
278
+ btn.setAttribute('aria-busy', busy ? 'true' : 'false');
279
+ },
280
+ };
281
+
282
+ /** A full-width row inside the grid — messages must not be laid out as a tile. */
283
+ function message(text: string, retry?: () => void): HTMLElement {
284
+ const box = styled('div', 'atx-media-status');
285
+ box.textContent = text;
286
+ if (retry) {
287
+ const btn = styled('button', 'atx-btn atx-btn-outline atx-btn-retry');
288
+ btn.type = 'button';
289
+ btn.textContent = 'Retry';
290
+ btn.addEventListener('click', retry);
291
+ box.append(btn);
292
+ }
293
+ return box;
294
+ }
295
+ }
296
+
297
+ /** The caption sits *outside* the pick button — see the module header. */
298
+ function buildCaption(caption: TileCaption): HTMLElement {
299
+ const cap = styled('div', 'atx-media-cap');
300
+
301
+ if (caption.kind === 'name') {
302
+ cap.textContent = caption.text;
303
+ if (caption.title) cap.title = caption.title;
304
+ return cap;
305
+ }
306
+
307
+ // Credit is permanently visible rather than revealed on hover: it is what the
308
+ // API guidelines ask for, and there is no stylesheet to hang a hover on.
309
+ cap.dataset.credit = '';
310
+ const author = link('atx-unsplash-author', caption.photographer, caption.photographerUrl);
311
+ const source = link('atx-unsplash-link', 'Unsplash', caption.pageUrl);
312
+ cap.append(author, document.createTextNode(' · '), source);
313
+ cap.title = `${caption.photographer} on Unsplash`;
314
+ return cap;
315
+ }
316
+
317
+ function link(className: string, text: string, href: string): HTMLAnchorElement {
318
+ const a = styled('a', `atx-unsplash-credit ${className}`);
319
+ a.href = href;
320
+ a.target = '_blank';
321
+ a.rel = 'noreferrer';
322
+ a.textContent = text;
323
+ // The tile's own click handler must not fire when the credit is clicked.
324
+ a.addEventListener('click', (e) => e.stopPropagation());
325
+ return a;
326
+ }