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,309 @@
1
+ import type { FieldDescriptor, FieldType } from '../../shared/protocol.ts';
2
+ import { inputEl, styled } from '../ui.ts';
3
+ import { buildImageField } from './asset-picker.ts';
4
+
5
+ /**
6
+ * Field controls for the entry drawer *and* the Settings drawer: one builder per
7
+ * FieldType, looked up through a registry (mirroring src/patcher/registry.ts).
8
+ * Adding a widget = add the FieldType to protocol.ts, register a builder here,
9
+ * and (if it should be schema-derived rather than config-forced) map it in
10
+ * server/schema-introspect.ts. Unknown types degrade to the read-only `json`
11
+ * builder, so a stale client never crashes on a new wire value.
12
+ *
13
+ * The Settings drawer describes each integration option as a synthesized
14
+ * {@link FieldDescriptor} and comes through here too, rather than growing a
15
+ * parallel control system. That is what `readOnly` and `help` on the descriptor
16
+ * are for: an option `astro.config.mjs` owns must render disabled (accepting
17
+ * input for a value resolution would discard is a lie), and an option needs a
18
+ * line of prose next to it far more often than a frontmatter key does.
19
+ */
20
+
21
+ export interface FieldControl {
22
+ field: FieldDescriptor;
23
+ root: HTMLElement;
24
+ /** Current wire value for this field. */
25
+ value(): unknown;
26
+ /** Whether the user changed it from its initial state. */
27
+ dirty(): boolean;
28
+ setError(message: string | null): void;
29
+ }
30
+
31
+ /** Grey out and block input on every control a builder mounted. Applied after
32
+ * the builder runs, so no builder has to know about `readOnly` — including the
33
+ * image picker, whose button is not an input at all. */
34
+ function lockControls(root: HTMLElement): void {
35
+ for (const el of root.querySelectorAll('input, textarea, select, button')) {
36
+ (el as HTMLInputElement | HTMLButtonElement).disabled = true;
37
+ }
38
+ root.dataset.locked = '';
39
+ }
40
+
41
+ /** What a builder must supply; buildControl adds the label/error chrome. */
42
+ interface ControlParts {
43
+ value(): unknown;
44
+ dirty(): boolean;
45
+ }
46
+
47
+ interface ControlContext {
48
+ field: FieldDescriptor;
49
+ /** Raw parsed frontmatter value (undefined for a new entry). */
50
+ raw: unknown;
51
+ /** `raw` rendered for display (see displayValue). */
52
+ initial: string;
53
+ /** Schema-default hint shown when the key is absent from the file. */
54
+ placeholder: string;
55
+ /** Mount point: append the control's element(s) here. */
56
+ root: HTMLElement;
57
+ /** Repo-relative path of the entry being edited; '' for a new one. Needed by
58
+ * controls whose values are relative to the file (see FieldDescriptor.assetRef). */
59
+ entryFile: string;
60
+ }
61
+
62
+ type ControlBuilder = (ctx: ControlContext) => ControlParts;
63
+
64
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
65
+
66
+ /** Initial display value for a control, from the parsed frontmatter. */
67
+ function displayValue(field: FieldDescriptor, raw: unknown): string {
68
+ if (raw === undefined || raw === null) return '';
69
+ if (field.type === 'tags' && Array.isArray(raw)) return raw.join(', ');
70
+ if (field.type === 'json') return JSON.stringify(raw, null, 2);
71
+ return String(raw);
72
+ }
73
+
74
+ // --- builders ----------------------------------------------------------------
75
+
76
+ /** text / date / number / tags share a plain input. */
77
+ const plainInput: ControlBuilder = ({ field, initial, placeholder, root }) => {
78
+ const input = inputEl('input', 'atx-field-input');
79
+ if (field.type === 'date' && (initial === '' || DATE_RE.test(initial))) {
80
+ input.type = 'date';
81
+ input.value = initial.slice(0, 10);
82
+ } else if (field.type === 'number') {
83
+ input.type = 'number';
84
+ input.value = initial;
85
+ } else {
86
+ input.type = 'text';
87
+ input.value = initial;
88
+ }
89
+ input.placeholder = placeholder;
90
+ const started = input.value;
91
+ root.append(input);
92
+ return {
93
+ value: () => {
94
+ if (field.type === 'number') return input.value === '' ? '' : Number(input.value);
95
+ if (field.type === 'tags') {
96
+ return input.value.split(',').map((s) => s.trim()).filter(Boolean);
97
+ }
98
+ return input.value;
99
+ },
100
+ dirty: () => input.value !== started,
101
+ };
102
+ };
103
+
104
+ const checkbox: ControlBuilder = ({ field, raw, root }) => {
105
+ // A span, not a label: wrapping the input in one would make the state word
106
+ // the control's accessible name — "On", where the field's own label says
107
+ // which thing is on. The words describe the box; the field label names it.
108
+ const wrap = styled('span', 'atx-field-check');
109
+ const input = styled('input', 'atx-field-input atx-field-checkbox');
110
+ input.type = 'checkbox';
111
+ input.checked = raw === true;
112
+ // A bare checkbox reads as unfinished UI, so the box is always accompanied by
113
+ // words: "not set" while the key is absent from the file (the state the entry
114
+ // drawer has to distinguish), and the plain on/off state once it is not.
115
+ // An absent key is not Off — the schema's default is what will apply — so the
116
+ // words say which default that is, and go back to saying it if the box is
117
+ // ticked and unticked again.
118
+ const hint = styled('span', 'atx-field-check-hint');
119
+ const stateWord = (): string => (input.checked ? 'On' : 'Off');
120
+ const unsetWord =
121
+ field.defaultValue === undefined
122
+ ? 'not set'
123
+ : `not set — defaults to ${field.defaultValue === true ? 'On' : 'Off'}`;
124
+ const render = (): void => {
125
+ hint.textContent = !field.present && !input.checked ? unsetWord : stateWord();
126
+ };
127
+ render();
128
+ input.addEventListener('change', render);
129
+ wrap.append(input, hint);
130
+ root.append(wrap);
131
+ return {
132
+ value: () => input.checked,
133
+ dirty: () => input.checked !== (raw === true),
134
+ };
135
+ };
136
+
137
+ const select: ControlBuilder = ({ field, initial, placeholder, root }) => {
138
+ const el = inputEl('select', 'atx-field-input atx-field-select');
139
+ const opts = [...(field.options ?? [])];
140
+ if (initial && !opts.includes(initial)) opts.unshift(initial);
141
+ if (!field.present) {
142
+ const blank = document.createElement('option');
143
+ blank.value = '';
144
+ blank.textContent = placeholder || '—';
145
+ el.append(blank);
146
+ }
147
+ for (const o of opts) {
148
+ const opt = document.createElement('option');
149
+ opt.value = o;
150
+ opt.textContent = o;
151
+ el.append(opt);
152
+ }
153
+ el.value = initial;
154
+ root.append(el);
155
+ return { value: () => el.value, dirty: () => el.value !== initial };
156
+ };
157
+
158
+ const textarea: ControlBuilder = ({ initial, placeholder, root }) => {
159
+ const input = inputEl('textarea', 'atx-field-input atx-field-textarea');
160
+ input.value = initial;
161
+ input.placeholder = placeholder;
162
+ root.append(input);
163
+ return { value: () => input.value, dirty: () => input.value !== initial };
164
+ };
165
+
166
+ const image: ControlBuilder = ({ field, initial, root, entryFile }) => {
167
+ let current = initial;
168
+ root.append(
169
+ buildImageField({
170
+ initial,
171
+ onChange: (next) => (current = next),
172
+ // An image() field stores a path relative to the entry file, not a web
173
+ // URL — the control resolves previews and writes picks in that shape.
174
+ ...(field.assetRef ? { assetRef: field.assetRef, entryFile } : {}),
175
+ }),
176
+ );
177
+ return { value: () => current, dirty: () => current !== initial };
178
+ };
179
+
180
+ /** Shapes the panel can't edit render read-only; saves never touch them. */
181
+ const json: ControlBuilder = ({ raw, initial, root }) => {
182
+ const input = inputEl('textarea', 'atx-field-input atx-field-json');
183
+ input.value = initial;
184
+ input.readOnly = true;
185
+ input.title = 'This field has a shape the panel can’t edit — change it in the file.';
186
+ root.append(input);
187
+ return { value: () => raw, dirty: () => false };
188
+ };
189
+
190
+ const CONTROL_BUILDERS: Record<FieldType, ControlBuilder> = {
191
+ text: plainInput,
192
+ date: plainInput,
193
+ number: plainInput,
194
+ tags: plainInput,
195
+ boolean: checkbox,
196
+ select,
197
+ textarea,
198
+ image,
199
+ json,
200
+ };
201
+
202
+ // --- assembly ----------------------------------------------------------------
203
+
204
+ /** Ids are only ever looked up inside the overlay's shadow root, so a counter
205
+ * is enough to keep `for`/`aria-describedby` unambiguous. */
206
+ let controlSeq = 0;
207
+
208
+ export function buildControl(
209
+ field: FieldDescriptor,
210
+ raw: unknown,
211
+ entryFile = '',
212
+ ): FieldControl {
213
+ const root = styled('div', 'atx-field');
214
+ const id = `atx-field-${++controlSeq}`;
215
+
216
+ const label = styled('label', 'atx-field-label');
217
+ label.textContent = field.required ? `${field.label} *` : field.label;
218
+ label.htmlFor = id;
219
+ root.append(label);
220
+
221
+ const error = styled('div', 'atx-field-error');
222
+ // A live region, so the message is announced when it appears rather than
223
+ // only being found by someone who happens to navigate back over the field.
224
+ error.role = 'alert';
225
+ const setError = (message: string | null): void => {
226
+ error.textContent = message ?? '';
227
+ error.toggleAttribute('data-on', Boolean(message));
228
+ // The destructive border is drawn from aria-invalid rather than from a
229
+ // class of its own, so the thing a screen reader is told and the thing the
230
+ // eye is shown are the same fact instead of two that can disagree.
231
+ for (const el of root.querySelectorAll('[data-input]')) {
232
+ if (message) el.setAttribute('aria-invalid', 'true');
233
+ else el.removeAttribute('aria-invalid');
234
+ }
235
+ };
236
+
237
+ const initial = displayValue(field, raw);
238
+ const placeholder =
239
+ !field.present && field.defaultValue !== undefined
240
+ ? `${displayValue({ ...field, present: true }, field.defaultValue)} (default)`
241
+ : '';
242
+
243
+ const builder = CONTROL_BUILDERS[field.type] ?? json;
244
+ const parts = builder({ field, raw, initial, placeholder, root, entryFile });
245
+
246
+ // The visible label has to *be* the control's name, not a sibling that reads
247
+ // like one: a builder mounts whatever it likes, so the association is made
248
+ // here, on the first form element it mounted. That is the control proper in
249
+ // every builder — the image field's preview and Browse are buttons around
250
+ // its path input, and both open the same picker the label's click does not
251
+ // need to.
252
+ const control = root.querySelector('input, textarea, select');
253
+ if (control) control.id = id;
254
+
255
+ // Anything that qualifies the control rather than naming it is a description:
256
+ // the help line, and the checkbox's state word, which says "not set" where
257
+ // the box alone can only say unticked.
258
+ const described: string[] = [];
259
+ const hint = root.querySelector('.atx-field-check-hint');
260
+ if (hint) {
261
+ hint.id = `${id}-state`;
262
+ described.push(hint.id);
263
+ }
264
+
265
+ if (field.help) {
266
+ const help = styled('div', 'atx-field-help');
267
+ help.id = `${id}-help`;
268
+ help.textContent = field.help;
269
+ root.append(help);
270
+ described.push(help.id);
271
+ }
272
+
273
+ error.id = `${id}-error`;
274
+ described.push(error.id);
275
+ if (control) control.setAttribute('aria-describedby', described.join(' '));
276
+
277
+ root.append(error);
278
+
279
+ if (field.readOnly) {
280
+ lockControls(root);
281
+ // Reported clean regardless of what the control holds: a locked field can
282
+ // never contribute to a save, so `collectChanges` must not see it.
283
+ return { field, root, value: parts.value, dirty: () => false, setError };
284
+ }
285
+ return { field, root, value: parts.value, dirty: parts.dirty, setError };
286
+ }
287
+
288
+ /** The frontmatter payload for changed fields only. Clearing an optional
289
+ * field maps to null (= remove the key); required fields send '' and let the
290
+ * server's schema validation answer. */
291
+ export function collectChanges(controls: FieldControl[]): Record<string, unknown> {
292
+ const changes: Record<string, unknown> = {};
293
+ for (const c of controls) {
294
+ if (!c.dirty()) continue;
295
+ const v = c.value();
296
+ const emptied = v === '' || (Array.isArray(v) && v.length === 0);
297
+ if (emptied && !c.field.required && c.field.present) changes[c.field.name] = null;
298
+ else if (emptied && !c.field.present) continue; // never present, still empty
299
+ else changes[c.field.name] = v;
300
+ }
301
+ return changes;
302
+ }
303
+
304
+ export function applyFieldErrors(
305
+ controls: FieldControl[],
306
+ fieldErrors: Record<string, string>,
307
+ ): void {
308
+ for (const c of controls) c.setError(fieldErrors[c.field.name] ?? null);
309
+ }
@@ -0,0 +1,268 @@
1
+ import type { ApplyOp, AssetInfo, AttrState, SourceLoc } from '../../shared/protocol.ts';
2
+ import * as api from '../api.ts';
3
+ import { clearHighlight } from '../hover.ts';
4
+ import { trapFocus } from '../focus.ts';
5
+ import * as state from '../state.ts';
6
+ import { icon } from '../icons.ts';
7
+ import {
8
+ basename,
9
+ buildBackdrop,
10
+ buildPanel,
11
+ footButton,
12
+ lockElement,
13
+ setFreshSrc,
14
+ styled,
15
+ toast,
16
+ wirePanelButtons,
17
+ } from '../ui.ts';
18
+ import { openMediaModal } from './media-modal.ts';
19
+ import { webPathToUrl } from '../../shared/asset-path.ts';
20
+ import { mount } from '../shadow.ts';
21
+
22
+ /**
23
+ * Image swap panel: a preview of the image as it is now, its alt text, and a
24
+ * way to replace it.
25
+ *
26
+ * It stopped being a browser. Picking from hundreds of project images (or from
27
+ * Unsplash) is the media modal's job; this panel keeps only what belongs to
28
+ * *this* element — the preview, the alt field, and a strip of the few most
29
+ * recently added images for the common "swap in the thing I just uploaded"
30
+ * case, with `Browse all` opening the modal for everything else.
31
+ *
32
+ * Only statically-quoted attributes are patchable; a missing alt can be added.
33
+ * (spec §6.3)
34
+ */
35
+
36
+ /** How many recent images the quick strip offers before you need the modal. */
37
+ const RECENTS = 6;
38
+
39
+ export async function beginImageEdit(
40
+ img: HTMLImageElement,
41
+ src: SourceLoc,
42
+ attrs: { src: AttrState; alt: AttrState },
43
+ ): Promise<void> {
44
+ clearHighlight();
45
+ const originalSrc = img.getAttribute('src') ?? '';
46
+ const originalAlt = img.getAttribute('alt') ?? '';
47
+ // Only statically-quoted attributes can be patched; expression values must be
48
+ // edited in the source. A missing alt can be added (img only). (spec §6.3)
49
+ const srcEditable = attrs.src === 'static';
50
+ const altEditable = attrs.alt !== 'dynamic';
51
+
52
+ const panel = buildPanel(`Image · ${basename(src.file)}:${src.loc}`, undefined, {
53
+ width: 'min(520px, 92vw)',
54
+ });
55
+ const body = panel.querySelector('[data-body]') as HTMLElement;
56
+
57
+ /**
58
+ * What the panel will save, in two shapes, because the picker's path and the
59
+ * attribute are not the same string.
60
+ *
61
+ * `chosenSrc` is the **web path** — what `/assets` lists and what the modal
62
+ * matches its selection against, so every comparison here uses it.
63
+ * `chosenSrcAttr` is that path in **URL form**, which is what goes into the
64
+ * source file. They differ only for a filename holding a character a URL path
65
+ * must encode; a space is the one that turns up. Both start as what the
66
+ * element already has, which is already a URL and is never re-encoded — a
67
+ * hand-written `%20` must survive an alt-only save untouched.
68
+ */
69
+ let chosenSrc = originalSrc;
70
+ let chosenSrcAttr = originalSrc;
71
+
72
+ // --- preview ---------------------------------------------------------------
73
+ // Shown even when the file cannot be swapped: writing alt text for an image
74
+ // you cannot see is the exact problem this fixes, and the preview is read
75
+ // from the DOM rather than from anything patchable.
76
+ const preview = styled('div', 'atx-image-preview');
77
+ const previewImg = styled('img', 'atx-image-preview-img');
78
+ previewImg.alt = '';
79
+ previewImg.decoding = 'async';
80
+ // A path that fails to load hides rather than showing a broken-image icon;
81
+ // a retry that finally succeeds undoes that — see ui.ts::setFreshSrc.
82
+ previewImg.addEventListener('error', () => previewImg.toggleAttribute('data-hidden', true));
83
+ previewImg.addEventListener('load', () => previewImg.toggleAttribute('data-hidden', false));
84
+ preview.append(previewImg);
85
+
86
+ const meta = styled('p', 'atx-image-meta');
87
+
88
+ /** `fresh` marks a file written seconds ago, which needs the retrying loader;
89
+ * `path` is always the clean value the metadata line and the save refer to. */
90
+ const setPreview = (path: string, fresh = false): void => {
91
+ if (fresh) setFreshSrc(previewImg, path);
92
+ else previewImg.src = path;
93
+ meta.textContent = path ? basename(path) : '';
94
+ meta.title = path;
95
+ };
96
+ setPreview(originalSrc);
97
+ body.append(preview, meta);
98
+
99
+ if (!srcEditable) {
100
+ const note = styled('p', 'atx-note');
101
+ note.textContent =
102
+ 'The image file is set from code (an expression or astro:assets), so it can’t be swapped here — only the alt text can be edited.';
103
+ body.append(note);
104
+ }
105
+
106
+ // --- alt text --------------------------------------------------------------
107
+ const altLabel = styled('label', 'atx-alt-label');
108
+ altLabel.textContent = 'Alt text';
109
+ const altInput = styled('input', 'atx-alt-input');
110
+ altInput.value = originalAlt;
111
+ if (!altEditable) {
112
+ altInput.disabled = true;
113
+ altInput.title = 'The alt text is set from an expression — edit it in the source.';
114
+ altInput.toggleAttribute('data-off', true);
115
+ }
116
+ body.append(altLabel, altInput);
117
+
118
+ const close = (commit: boolean): void => {
119
+ state.releaseIf(token);
120
+ releaseFocus();
121
+ panel.remove();
122
+ backdrop.remove();
123
+ if (!commit) {
124
+ img.setAttribute('src', originalSrc);
125
+ img.setAttribute('alt', originalAlt);
126
+ return;
127
+ }
128
+ const nextAlt = altInput.value;
129
+ if (chosenSrcAttr === originalSrc && nextAlt === originalAlt) return;
130
+ void commitImageEdit(img, src, { originalSrc, originalAlt, nextSrc: chosenSrcAttr, nextAlt });
131
+ };
132
+
133
+ const backdrop = buildBackdrop(() => close(false));
134
+ wirePanelButtons(panel, () => close(false), () => close(true));
135
+ const token = state.begin({ kind: 'panel', close: () => close(false) });
136
+ mount(backdrop, panel);
137
+ const releaseFocus = trapFocus(panel, { initial: altInput });
138
+
139
+ // --- replace ---------------------------------------------------------------
140
+ if (!srcEditable) return;
141
+
142
+ /** Stage a replacement: preview it here and on the page, but write nothing
143
+ * until Save. `fresh` marks a file written seconds ago, which needs the
144
+ * retrying loader to survive Vite's 404 window (see ui.ts::setFreshSrc). */
145
+ const stage = (webPath: string, fresh = false): void => {
146
+ chosenSrc = webPath;
147
+ chosenSrcAttr = webPathToUrl(webPath);
148
+ setPreview(webPath, fresh);
149
+ // Live preview on the page itself.
150
+ if (fresh) setFreshSrc(img, webPath);
151
+ else img.setAttribute('src', webPath);
152
+ paintRecents();
153
+ };
154
+
155
+ const strip = styled('div', 'atx-image-recents');
156
+ const stripLabel = styled('div', 'atx-image-recents-label');
157
+ const stripTitle = styled('span', 'atx-image-recents-title');
158
+ stripTitle.textContent = 'Recently added';
159
+ // The strip's corner action, on the same shape every other corner action in
160
+ // the overlay takes. The arrow is the chevron glyph rather than a "→" — a
161
+ // text arrow lands at a different weight and baseline in every platform font.
162
+ const browseAll = footButton('Browse all', 'outline', () => void browse());
163
+ browseAll.classList.add('atx-btn-sm', 'atx-image-browse-all');
164
+ browseAll.append(icon('chevronRight', 16));
165
+ stripLabel.append(stripTitle, browseAll);
166
+ body.append(stripLabel, strip);
167
+
168
+ let recents: AssetInfo[] = [];
169
+ const openedAt = Date.now();
170
+
171
+ function paintRecents(): void {
172
+ strip.textContent = '';
173
+ for (const asset of recents) {
174
+ const current = asset.path === chosenSrc;
175
+ const btn = styled('button', 'atx-image-recent');
176
+ btn.toggleAttribute('data-current', current);
177
+ btn.type = 'button';
178
+ btn.title = asset.path;
179
+ const thumb = styled('img', 'atx-image-recent-thumb');
180
+ // Anything written since this panel opened may still be in Vite's 404
181
+ // window, so it gets the retrying loader; everything else loads normally.
182
+ if (asset.mtime > openedAt) setFreshSrc(thumb, asset.path);
183
+ else thumb.src = asset.path;
184
+ thumb.alt = '';
185
+ thumb.loading = 'lazy';
186
+ thumb.decoding = 'async';
187
+ thumb.addEventListener('error', () => thumb.toggleAttribute('data-hidden', true));
188
+ // A retry that finally succeeds must undo that — see ui.ts::setFreshSrc.
189
+ thumb.addEventListener('load', () => thumb.toggleAttribute('data-hidden', false));
190
+ btn.append(thumb);
191
+ btn.addEventListener('click', () => stage(asset.path));
192
+ strip.append(btn);
193
+ }
194
+ }
195
+
196
+ /** Everything beyond the six most recent lives in the modal. */
197
+ async function browse(): Promise<void> {
198
+ const pick = await openMediaModal({
199
+ title: 'Replace image',
200
+ ...(chosenSrc ? { currentWebPath: chosenSrc } : {}),
201
+ });
202
+ if (!pick) return; // cancelled — nothing staged
203
+ stage(pick.webPath, pick.origin !== 'existing');
204
+ // A newly uploaded or imported file belongs at the head of the strip.
205
+ void loadRecents();
206
+ }
207
+
208
+ async function loadRecents(): Promise<void> {
209
+ try {
210
+ const { files } = await api.getAssets();
211
+ // Same rule as the modal's project pane: a plain `<img src>` can only
212
+ // reference paths that exist in the built site. The strip is six tiles of
213
+ // shortcut, so an unusable file is left out rather than shown disabled —
214
+ // the explaining is Browse all's job. (issue #9)
215
+ recents = files
216
+ .filter((f) => f.servable)
217
+ .sort((a, b) => b.mtime - a.mtime)
218
+ .slice(0, RECENTS);
219
+ // The current image's own metadata, now that we have the listing.
220
+ const self = files.find((f) => f.path === chosenSrc);
221
+ if (self) meta.textContent = `${basename(self.path)} · ${formatBytes(self.size)}`;
222
+ paintRecents();
223
+ } catch {
224
+ // The strip is a convenience; the modal's Browse all still works, and it
225
+ // reports its own failure with a Retry.
226
+ stripLabel.toggleAttribute('data-hidden', true);
227
+ }
228
+ }
229
+
230
+ void loadRecents();
231
+ }
232
+
233
+ function formatBytes(bytes: number): string {
234
+ if (bytes < 1024) return `${bytes} B`;
235
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
236
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
237
+ }
238
+
239
+ async function commitImageEdit(
240
+ img: HTMLImageElement,
241
+ src: SourceLoc,
242
+ v: { originalSrc: string; originalAlt: string; nextSrc: string; nextAlt: string },
243
+ ): Promise<void> {
244
+ const busy = state.begin({ kind: 'busy' });
245
+ const release = lockElement(img);
246
+ try {
247
+ img.setAttribute('src', v.nextSrc);
248
+ img.setAttribute('alt', v.nextAlt);
249
+ // One batched apply: the server verifies both attrs and writes once, so a
250
+ // src+alt change can never leave the file half-updated. (spec §6.3)
251
+ const ops: ApplyOp[] = [];
252
+ if (v.nextSrc !== v.originalSrc) {
253
+ ops.push({ targetType: 'src', original: v.originalSrc, newText: v.nextSrc });
254
+ }
255
+ if (v.nextAlt !== v.originalAlt) {
256
+ ops.push({ targetType: 'alt', original: v.originalAlt, newText: v.nextAlt });
257
+ }
258
+ if (ops.length) await api.apply({ file: src.file, loc: src.loc, tag: 'img', ops });
259
+ toast(`Saved — ${basename(src.file)}:${src.loc}`, 'ok');
260
+ } catch (err) {
261
+ img.setAttribute('src', v.originalSrc);
262
+ img.setAttribute('alt', v.originalAlt);
263
+ toast(`Save failed — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
264
+ } finally {
265
+ release();
266
+ state.releaseIf(busy);
267
+ }
268
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The markup popup's tag palette, and the pure text/caret math behind it.
3
+ *
4
+ * Separated from `markup.ts` so the wrapping and caret rules — the part with
5
+ * off-by-ones in it — can be unit-tested without a DOM. Nothing here touches
6
+ * the document; `markup.ts` applies the result to its textarea.
7
+ */
8
+
9
+ export interface TagSpec {
10
+ tag: string;
11
+ /** Never closes — inserted alone, never wrapping. */
12
+ isVoid?: boolean;
13
+ /** Attribute pre-filled on insert, with the caret left inside its quotes. */
14
+ attr?: string;
15
+ }
16
+
17
+ /**
18
+ * The tags the palette offers. Kept in step with `INLINE_TAGS` in
19
+ * `src/patcher/astro.ts`, which is the authority: drift here shows up as a
20
+ * refusal on save, never as a bad write.
21
+ */
22
+ export const TAGS: TagSpec[] = [
23
+ { tag: 'br', isVoid: true },
24
+ { tag: 'strong' },
25
+ { tag: 'em' },
26
+ { tag: 'b' },
27
+ { tag: 'i' },
28
+ { tag: 'u' },
29
+ { tag: 'a', attr: 'href=""' },
30
+ { tag: 'span' },
31
+ { tag: 'code' },
32
+ { tag: 'small' },
33
+ { tag: 'sup' },
34
+ { tag: 'sub' },
35
+ ];
36
+
37
+ export interface Insertion {
38
+ /** Text to put in place of [start, end). */
39
+ text: string;
40
+ /** Selection to leave behind, as offsets from `start`. */
41
+ caretFrom: number;
42
+ caretTo: number;
43
+ }
44
+
45
+ /**
46
+ * What inserting `spec` should do to `selected` — the text currently
47
+ * highlighted in the popup, empty when the caret is just sitting somewhere.
48
+ *
49
+ * - A void tag is inserted at the caret and replaces any selection.
50
+ * - A pair with a selection wraps it and *keeps it selected*, so tags can be
51
+ * stacked without re-selecting (`<em>` then `<strong>`).
52
+ * - A pair with no selection leaves the caret between the two halves.
53
+ * - A tag with a pre-filled attribute (`<a href="">`) always leaves the caret
54
+ * inside the quotes instead, since the URL is the next thing to type.
55
+ */
56
+ export function tagInsertion(selected: string, spec: TagSpec): Insertion {
57
+ if (spec.isVoid) {
58
+ const text = `<${spec.tag}>`;
59
+ return { text, caretFrom: text.length, caretTo: text.length };
60
+ }
61
+
62
+ const open = spec.attr ? `<${spec.tag} ${spec.attr}>` : `<${spec.tag}>`;
63
+ const text = `${open}${selected}</${spec.tag}>`;
64
+
65
+ if (spec.attr) {
66
+ const inQuotes = open.length - 2;
67
+ return { text, caretFrom: inQuotes, caretTo: inQuotes };
68
+ }
69
+ if (selected) {
70
+ return { text, caretFrom: open.length, caretTo: open.length + selected.length };
71
+ }
72
+ return { text, caretFrom: open.length, caretTo: open.length };
73
+ }