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,457 @@
1
+ import type { FieldDescriptor, OptionDescriptor, SettingsResponse } from '../../shared/protocol.ts';
2
+ import * as api from '../api.ts';
3
+ import { setFeatures } from '../features.ts';
4
+ import { coerceImportWidth } from '../../shared/unsplash.ts';
5
+ import { clearHighlight } from '../hover.ts';
6
+ import { icon } from '../icons.ts';
7
+ import {
8
+ inputEl,
9
+ buildTabs,
10
+ footButton,
11
+ setButtonEnabled,
12
+ styled,
13
+ toast,
14
+ } from '../ui.ts';
15
+ import { card, fieldGroup } from '../group.ts';
16
+ import { openDrawer } from './drawer.ts';
17
+ import { applyFieldErrors, buildControl, collectChanges, type FieldControl } from './fields.ts';
18
+
19
+ /**
20
+ * The Settings drawer — every integration option, editable in place.
21
+ *
22
+ * **The panel does not know what the options are.** `GET /settings` returns a
23
+ * list of {@link OptionDescriptor}s — label, help, control type, current value,
24
+ * provenance — and this module renders whatever arrives, grouped into tabs by
25
+ * the `group` each one declares. Adding an option is one entry in the server's
26
+ * `OPTION_SPECS` table and no client change at all. Each descriptor becomes a
27
+ * synthesized {@link FieldDescriptor} so the controls come from the entry
28
+ * editor's own `buildControl` registry rather than a parallel one.
29
+ *
30
+ * **`locked` is rendered, not hidden.** An option `astro.config.mjs` sets cannot
31
+ * be changed from here, because config wins at resolve time. The control renders
32
+ * disabled and says where the value came from. Hiding those rows would be worse:
33
+ * the user would wonder why the option they can see in their config isn't listed.
34
+ *
35
+ * **The access key has two axes, not one.** `locked` is a single bit; a key can
36
+ * be *writable but not clearable* — one in `.env` is overridden by the
37
+ * `.env.local` a save writes, yet removing a line from `.env.local` cannot unset
38
+ * it. So the server sends `writable` and `clearable` separately and this panel
39
+ * renders them, rather than re-deriving either from `source`: which env file won
40
+ * decides the answer, and a second copy of that precedence here would drift.
41
+ *
42
+ * **The access key stays one-way.** It is never pre-filled — a read returns only
43
+ * a masked hint — the input is `type="password"` with an explicit reveal, and the
44
+ * value goes straight to the localhost-gated `/settings` endpoint. That POST is
45
+ * the one moment the key crosses the wire in plaintext, unavoidable for a
46
+ * paste-it-here UI, and its destination is the same `.env.local` a developer
47
+ * would otherwise have opened in their editor.
48
+ */
49
+
50
+ const UNSPLASH_APPS_URL = 'https://unsplash.com/oauth/applications';
51
+
52
+ /** Where a resolved key came from, in words. `env-file` prefers the actual
53
+ * filename the server reported, since which file it is decides what the user
54
+ * has to do about it. */
55
+ const SOURCE_LABEL: Record<string, string> = {
56
+ config: 'astro.config.mjs',
57
+ 'env-shell': 'an exported shell variable',
58
+ 'env-file': 'a .env file',
59
+ file: '.astro-dev-edit.json (older location)',
60
+ };
61
+
62
+ const sourceLabel = (u: SettingsResponse['unsplash']): string =>
63
+ (u.source === 'env-file' && u.sourceFile) || SOURCE_LABEL[u.source ?? ''] || 'stored settings';
64
+
65
+ /** Tabs, in render order. A group with no options is dropped, so a server that
66
+ * predates a group simply shows fewer tabs. */
67
+ const GROUPS: ReadonlyArray<{ id: string; label: string; blurb: string }> = [
68
+ {
69
+ id: 'general',
70
+ label: 'General',
71
+ blurb: 'What the editor is allowed to touch, and how it finds your source.',
72
+ },
73
+ {
74
+ id: 'editing',
75
+ label: 'Editing',
76
+ blurb: 'Which editing surfaces the overlay offers.',
77
+ },
78
+ {
79
+ id: 'media',
80
+ label: 'Media',
81
+ blurb: 'Where images are read from, and where new ones are written.',
82
+ },
83
+ {
84
+ id: 'unsplash',
85
+ label: 'Unsplash',
86
+ blurb: 'An optional photo source in the media picker.',
87
+ },
88
+ ];
89
+
90
+ export interface SettingsPanelOptions {
91
+ /** Open on a particular tab. Used by the media modal's "no key configured"
92
+ * card, which wants the Unsplash tab specifically. */
93
+ tab?: string;
94
+ /** Stacking layer, when opened above something already raised — that same
95
+ * card opens this from the media modal's own layer. */
96
+ layer?: number;
97
+ /** Run after the drawer closes, whatever the outcome. */
98
+ onClose?(): void;
99
+ }
100
+
101
+ export function openSettingsPanel(opts: SettingsPanelOptions = {}): void {
102
+ clearHighlight();
103
+
104
+ let controls: FieldControl[] = [];
105
+ let current: SettingsResponse | null = null;
106
+ let saving = false;
107
+ /** Set once the key input has been typed into — the only dirty state the key
108
+ * field can report, since it never pre-fills. */
109
+ const keyDirty = (): boolean => keyInput.value.trim().length > 0;
110
+
111
+ const isDirty = (): boolean =>
112
+ !saving && (keyDirty() || Object.keys(collectChanges(controls)).length > 0);
113
+
114
+ const shell = openDrawer('Settings', {
115
+ isDirty,
116
+ discardMessage: 'Discard unsaved settings changes?',
117
+ width: 'min(max(520px, 44vw), 94vw)',
118
+ ...(opts.layer !== undefined ? { layer: opts.layer, restoreState: true } : {}),
119
+ ...(opts.onClose ? { onClose: opts.onClose } : {}),
120
+ });
121
+ const { body, foot } = shell;
122
+
123
+ // --- the option panes ------------------------------------------------------
124
+ const panes = new Map<string, HTMLElement>();
125
+ for (const g of GROUPS) {
126
+ const pane = styled('div', `atx-settings-pane atx-settings-pane-${g.id}`);
127
+ panes.set(g.id, pane);
128
+ }
129
+
130
+ const status = styled('p', 'atx-settings-status');
131
+
132
+ const error = styled('p', 'atx-settings-error');
133
+
134
+ // --- the Unsplash key section, which is not an option ----------------------
135
+ const keyCard = card({ title: 'Access key' });
136
+ const keySection = keyCard.root;
137
+ keySection.classList.add('atx-settings-key-section');
138
+
139
+ const keyBlurb = styled('p', 'atx-settings-blurb');
140
+ const appsLink = styled('a', 'atx-settings-link');
141
+ appsLink.href = UNSPLASH_APPS_URL;
142
+ appsLink.target = '_blank';
143
+ appsLink.rel = 'noreferrer';
144
+ appsLink.textContent = 'Create an application';
145
+ keyBlurb.append(
146
+ appsLink,
147
+ document.createTextNode(' to get an access key — the demo tier allows 50 searches an hour.'),
148
+ );
149
+
150
+ const keyStatus = styled('p', 'atx-settings-key-status');
151
+
152
+ const keyRow = styled('div', 'atx-settings-row');
153
+ // No masked input exists anywhere else in the overlay, so this is a plain
154
+ // control wearing the shared baseline rather than a reusable widget.
155
+ const keyInput = inputEl('input', 'atx-settings-key');
156
+ keyInput.type = 'password';
157
+ keyInput.autocomplete = 'off';
158
+ keyInput.spellcheck = false;
159
+ keyInput.placeholder = 'Paste your Unsplash access key';
160
+
161
+ const reveal = footButton('Show', 'outline', () => {
162
+ const hidden = keyInput.type === 'password';
163
+ keyInput.type = hidden ? 'text' : 'password';
164
+ reveal.textContent = hidden ? 'Hide' : 'Show';
165
+ });
166
+ keyRow.append(keyInput, reveal);
167
+
168
+ const keyHint = styled('p', 'atx-settings-hint');
169
+
170
+ const clearKeyBtn = footButton('Clear key', 'destructive', () => void save({ clearKey: true }));
171
+ const keyActions = styled('div', 'atx-settings-key-actions');
172
+ keyActions.append(clearKeyBtn);
173
+
174
+ keyCard.body.append(keyBlurb, keyStatus, keyRow, keyHint);
175
+ keyCard.foot().append(keyActions);
176
+
177
+ const warning = styled('p', 'atx-settings-warning');
178
+
179
+ const tabs = buildTabs(
180
+ GROUPS.map((g) => ({ id: g.id, label: g.label, pane: panes.get(g.id)! })),
181
+ { classPrefix: 'settings' },
182
+ );
183
+ body.append(status, tabs.strip, tabs.host, warning, error);
184
+
185
+ // --- rendering the server's answer -----------------------------------------
186
+ const paint = (data: SettingsResponse): void => {
187
+ current = data;
188
+ controls = [];
189
+ status.textContent = '';
190
+
191
+ const options = data.options ?? [];
192
+ for (const g of GROUPS) {
193
+ const pane = panes.get(g.id)!;
194
+ pane.textContent = '';
195
+ const mine = options.filter((o) => o.group === g.id);
196
+
197
+ // One card per tab: the group's name and its one-line blurb are the
198
+ // card's header, so the pane says what it governs instead of opening
199
+ // with an unattributed sentence above a run of controls.
200
+ const group = fieldGroup();
201
+ for (const o of mine) {
202
+ const control = buildControl(toFieldDescriptor(o), o.value);
203
+ if (o.locked) control.root.append(lockNote(o));
204
+ controls.push(control);
205
+ group.append(control.root);
206
+ }
207
+ if (mine.length > 0) {
208
+ const optionCard = card({ title: g.label, description: g.blurb });
209
+ optionCard.body.append(group);
210
+ pane.append(optionCard.root);
211
+ }
212
+
213
+ // The access key belongs to the Unsplash tab but is not an option: it is
214
+ // a secret with its own endpoint semantics and its own precedence — so
215
+ // it is a card of its own rather than a heading inside the options one.
216
+ if (g.id === 'unsplash') pane.append(keySection);
217
+ }
218
+
219
+ paintKey(data);
220
+ paintWarning(data);
221
+ setButtonEnabled(saveBtn, true);
222
+ };
223
+
224
+ const paintKey = (data: SettingsResponse): void => {
225
+ const u = data.unsplash;
226
+ keyStatus.textContent = '';
227
+
228
+ if (!u.enabled) {
229
+ // The old dead end lived here: this used to read "add `unsplash: {}` to
230
+ // astro.config.mjs, then restart the dev server". The toggle above is now
231
+ // the answer, so point at it instead.
232
+ keyStatus.append(
233
+ icon('dot', 16),
234
+ text('Turn the photo source on above to add a key', 'muted'),
235
+ );
236
+ keySection.toggleAttribute('data-off', true);
237
+ keyInput.disabled = true;
238
+ setButtonEnabled(reveal, false);
239
+ keyHint.textContent = '';
240
+ clearKeyBtn.toggleAttribute('data-hidden', true);
241
+ return;
242
+ }
243
+
244
+ keySection.toggleAttribute('data-off', false);
245
+
246
+ if (u.configured) {
247
+ keyStatus.append(icon('check', 16), text(`Configured via ${sourceLabel(u)}`, 'ok'));
248
+ if (u.hint) keyStatus.append(text(u.hint, 'muted', true));
249
+ } else {
250
+ keyStatus.append(icon('dot', 16), text('Not configured', 'muted'));
251
+ }
252
+
253
+ // `writable` is the server's answer, not ours — see the header.
254
+ const overridden = !u.writable;
255
+ keyInput.disabled = overridden;
256
+ setButtonEnabled(reveal, !overridden);
257
+ keyInput.placeholder = overridden
258
+ ? 'Overridden — remove the other key first'
259
+ : 'Paste your Unsplash access key';
260
+
261
+ if (overridden) {
262
+ keyHint.textContent =
263
+ `A key from ${sourceLabel(u)} takes precedence over the .env.local this panel ` +
264
+ 'writes. Remove it to manage the key from here.';
265
+ } else if (u.staleStoredKey) {
266
+ // The one case worth nagging about: a secret in a file that is not where
267
+ // secrets go any more. Saving anything moves it.
268
+ keyHint.textContent =
269
+ 'An older copy of your key is still stored in .astro-dev-edit.json. Save a key here ' +
270
+ 'to move it into .env.local and remove that copy.';
271
+ } else if (!u.clearable && u.configured) {
272
+ keyHint.textContent =
273
+ `A key is also set in ${u.sourceFile ?? '.env'}. Saving here writes .env.local, which ` +
274
+ 'takes precedence — remove that one when you are ready.';
275
+ } else {
276
+ keyHint.textContent =
277
+ 'Saved to .env.local at your project root as UNSPLASH_ACCESS_KEY, readable only by ' +
278
+ 'you (0600). It is never sent back to the browser.';
279
+ }
280
+ clearKeyBtn.toggleAttribute('data-hidden', !(u.configured && u.clearable));
281
+ };
282
+
283
+ /**
284
+ * The uncommitted-secret warning, painted from `paint` rather than from
285
+ * `paintKey`: it is a fact about files, not about the key. Scoping it to the
286
+ * key left it unreachable in exactly the case it is for — the photo source
287
+ * off, so `paintKey` returns early, while a file already sits untracked on
288
+ * disk holding whatever General or Media last saved. It lives under the tab
289
+ * host for the same reason, so it is on screen whichever tab is open.
290
+ *
291
+ * The server sends the list, because whether `.env.local` is even present is
292
+ * a question only it can answer.
293
+ */
294
+ const paintWarning = (data: SettingsResponse): void => {
295
+ const files = data.gitignoreWarning ?? [];
296
+ if (files.length > 0) {
297
+ const subject = files.length === 1 ? `${files[0]} is not listed` : `${files.join(' and ')} are not listed`;
298
+ warning.textContent =
299
+ `${subject} in this project’s .gitignore. They hold your settings and your Unsplash ` +
300
+ 'access key — add them to your ignore rules before either can be committed. ' +
301
+ '(This integration cannot edit your ignore rules for you.)';
302
+ warning.toggleAttribute('data-on', true);
303
+ } else {
304
+ warning.toggleAttribute('data-on', false);
305
+ }
306
+ };
307
+
308
+ const text = (value: string, tone: 'muted' | 'ok' | 'warn', mono = false): HTMLElement => {
309
+ const el = styled('span', 'atx-settings-text');
310
+ el.dataset.tone = tone;
311
+ if (mono) el.dataset.mono = '';
312
+ el.textContent = value;
313
+ return el;
314
+ };
315
+
316
+ /** Why a control is disabled, under the control. */
317
+ const lockNote = (o: OptionDescriptor): HTMLElement => {
318
+ // Muted, not amber: an option the project set in its own config is a
319
+ // normal state, and painting a third of the panel in warning colour would
320
+ // spend the one colour that should mean "something is wrong" — which here
321
+ // is the uncommitted-secret warning at the bottom.
322
+ const note = styled('p', 'atx-settings-lock');
323
+ note.append(
324
+ icon('lock', 12),
325
+ document.createTextNode(
326
+ o.restartRequired
327
+ ? 'Read before the dev server starts, so it lives in astro.config.mjs and changing it needs a restart.'
328
+ : 'Set in astro.config.mjs, which takes precedence. Remove it there to change it from here.',
329
+ ),
330
+ );
331
+ return note;
332
+ };
333
+
334
+ /** Freeze the drawer during a write. Un-freezing repaints from the current
335
+ * response rather than blanket-enabling, so a control disabled *by state* —
336
+ * a config-overridden key field — stays that way. */
337
+ const setBusy = (busy: boolean): void => {
338
+ saving = busy;
339
+ keyInput.readOnly = busy;
340
+ for (const btn of shell.body.querySelectorAll('button')) setButtonEnabled(btn, !busy);
341
+ for (const btn of foot.querySelectorAll('button')) setButtonEnabled(btn, !busy);
342
+ if (!busy) {
343
+ // Rebuilt by the paint that follows a successful save; on a failure this
344
+ // restores the pre-save enablement.
345
+ if (current) paintKey(current);
346
+ setButtonEnabled(saveBtn, current !== null);
347
+ }
348
+ };
349
+
350
+ const showError = (message: string): void => {
351
+ error.textContent = message;
352
+ error.toggleAttribute('data-on', true);
353
+ };
354
+
355
+ const save = async (o: { clearKey?: boolean } = {}): Promise<void> => {
356
+ error.toggleAttribute('data-on', false);
357
+ applyFieldErrors(controls, {});
358
+
359
+ const options = collectChanges(controls);
360
+ const key = o.clearKey ? '' : keyInput.value;
361
+ const sendKey = o.clearKey || key.trim().length > 0;
362
+ if (Object.keys(options).length === 0 && !sendKey) {
363
+ showError('Nothing has changed yet.');
364
+ return;
365
+ }
366
+
367
+ setBusy(true);
368
+ try {
369
+ const next = await api.saveSettings({
370
+ ...(Object.keys(options).length > 0 ? { options } : {}),
371
+ ...(sendKey ? { unsplash: { accessKey: key } } : {}),
372
+ });
373
+ // Feature flags the overlay reads are option-derived, so a save repaints
374
+ // the page's affordances without a reload.
375
+ setFeatures({
376
+ ok: true,
377
+ name: 'astro-dev-edit',
378
+ milestone: 1,
379
+ root: '',
380
+ cssInspector: valueOf(next, 'cssInspector') === true,
381
+ openInEditor: valueOf(next, 'openInEditor') === true,
382
+ entryEditor: valueOf(next, 'entryEditor') === true,
383
+ unsplash: next.unsplash.configured,
384
+ // A `select`'s value is a string on the wire; setFeatures parses it, so
385
+ // the picker's size select moves with a saved default. `?? undefined`
386
+ // because a null here would read as "the server has no such option".
387
+ unsplashImportWidth:
388
+ coerceImportWidth(valueOf(next, 'unsplashImportWidth')) ?? undefined,
389
+ });
390
+ keyInput.value = '';
391
+ keyInput.type = 'password';
392
+ reveal.textContent = 'Show';
393
+ paint(next);
394
+ toast(
395
+ o.clearKey ? 'Unsplash access key cleared' : 'Settings saved',
396
+ 'ok',
397
+ );
398
+ } catch (err) {
399
+ if (err instanceof api.SettingsRefusal) {
400
+ applyFieldErrors(controls, err.fieldErrors);
401
+ showError(err.message);
402
+ } else {
403
+ showError(err instanceof Error ? err.message : 'Could not save.');
404
+ }
405
+ } finally {
406
+ setBusy(false);
407
+ }
408
+ };
409
+
410
+ // --- footer ----------------------------------------------------------------
411
+ const closeBtn = footButton('Close', 'outline', () => shell.close());
412
+ const saveBtn = footButton('Save', 'default', () => void save());
413
+ foot.append(closeBtn, saveBtn);
414
+
415
+ // --- boot ------------------------------------------------------------------
416
+ status.append(icon('spinner', 16), text('Reading settings…', 'muted'));
417
+ setButtonEnabled(saveBtn, false);
418
+ clearKeyBtn.toggleAttribute('data-hidden', true);
419
+
420
+ void api.getSettings().then(
421
+ (data) => {
422
+ paint(data);
423
+ if (opts.tab) tabs.show(opts.tab);
424
+ },
425
+ (err: unknown) => {
426
+ status.textContent = '';
427
+ status.append(icon('alert', 16), text('Could not read settings', 'warn'));
428
+ showError(err instanceof Error ? err.message : 'The dev server did not answer.');
429
+ },
430
+ );
431
+ }
432
+
433
+ /** An option, described as a field so `buildControl` can render it.
434
+ *
435
+ * `present: true` and `required: false` throughout: an option always has an
436
+ * effective value (there is no "absent" state to hint a default for), and none
437
+ * of them can be cleared to nothing — `coerceOptionPatch` refuses an empty
438
+ * string or an empty list, so `collectChanges`' clear-to-null path is dead
439
+ * weight here rather than a hazard. */
440
+ function toFieldDescriptor(o: OptionDescriptor): FieldDescriptor {
441
+ return {
442
+ name: o.key,
443
+ label: o.label,
444
+ type: o.type,
445
+ required: false,
446
+ present: true,
447
+ source: 'inferred',
448
+ help: o.help,
449
+ ...(o.choices ? { options: o.choices } : {}),
450
+ ...(o.locked ? { readOnly: true } : {}),
451
+ };
452
+ }
453
+
454
+ /** An option's value out of a response, by key. */
455
+ function valueOf(data: SettingsResponse, key: string): unknown {
456
+ return data.options?.find((o) => o.key === key)?.value;
457
+ }
@@ -0,0 +1,166 @@
1
+ import { trapFocus } from '../focus.ts';
2
+ import { icon } from '../icons.ts';
3
+ import type { Interaction } from '../state.ts';
4
+ import * as state from '../state.ts';
5
+ import { mount } from '../shadow.ts';
6
+ import {
7
+ inputEl,
8
+ buildBackdrop,
9
+ buildPanel,
10
+ pillButton,
11
+ styled,
12
+ wirePanelButtons,
13
+ } from '../ui.ts';
14
+
15
+ /**
16
+ * The panel shape shared by every editor that edits *source* rather than
17
+ * rendered text — the markup popup and the traced-expression popup. Inline
18
+ * contenteditable can't serve either: one would escape the tags, the other
19
+ * would rewrite the template instead of the string behind it.
20
+ *
21
+ * What this owns, so its callers don't each re-implement it:
22
+ *
23
+ * - panel + backdrop + the interaction token, released exactly once;
24
+ * - the dirty flag the admin bar's exit button reads;
25
+ * - Cmd/Ctrl+Enter to save (plain Enter stays a newline — a panel has a Save
26
+ * button, unlike an inline edit);
27
+ * - **staying open when a save is refused.** A refusal here is an ordinary
28
+ * outcome — a disallowed tag, an ambiguous match — that the user fixes and
29
+ * retries, so the reason is shown inside the panel with the text intact
30
+ * rather than closing and losing the work.
31
+ */
32
+
33
+ export interface SourcePopupOptions {
34
+ /** Panel title bar, e.g. `Markup · index.astro:44:13`. */
35
+ title: string;
36
+ /** Label above the box. */
37
+ label: string;
38
+ /** Initial (and baseline) text. */
39
+ value: string;
40
+ /** Minimum height of the editing box. */
41
+ minHeight?: string;
42
+ /** Monospace box (source) rather than proportional (prose). */
43
+ mono?: boolean;
44
+ /** Optional controls under the box, built with the live textarea. */
45
+ tools?: (input: HTMLTextAreaElement, markDirty: () => void) => HTMLElement;
46
+ /**
47
+ * Write the edit. Resolve `null` on success — the panel then closes — or the
48
+ * message to show inside the still-open panel.
49
+ */
50
+ save: (value: string) => Promise<string | null>;
51
+ /**
52
+ * Jump to the source in the user's editor. Adds an **open** button to the
53
+ * title bar: what these popups edit *is* source, so the file it came from
54
+ * should be one click away — the same escape hatch the hover pill and the
55
+ * refusal notice offer. Editing continues; the panel stays open.
56
+ */
57
+ openSource?: () => void;
58
+ }
59
+
60
+ export function openSourcePopup(opts: SourcePopupOptions): void {
61
+ let jump: HTMLElement | undefined;
62
+ if (opts.openSource) {
63
+ const btn = pillButton(
64
+ 'atx-panel-open',
65
+ 'open',
66
+ 'Open this location in your editor',
67
+ { marginLeft: '0', flex: '0 0 auto' },
68
+ icon('external', 12),
69
+ );
70
+ btn.addEventListener('click', () => opts.openSource?.());
71
+ jump = btn;
72
+ }
73
+
74
+ const panel = buildPanel(opts.title, jump);
75
+ const body = panel.querySelector('[data-body]') as HTMLElement;
76
+
77
+ const label = styled('label', 'atx-popup-label');
78
+ label.textContent = opts.label;
79
+
80
+ // Only the caller's height override stays inline; the face is a flag, since
81
+ // both faces are fixed and only the choice between them is the caller's.
82
+ const input = inputEl(
83
+ 'textarea',
84
+ 'atx-popup-input',
85
+ opts.minHeight ? { minHeight: opts.minHeight } : undefined,
86
+ );
87
+ if (opts.mono) input.dataset.mono = '';
88
+ input.value = opts.value;
89
+
90
+ const error = styled('p', 'atx-popup-error');
91
+
92
+ const markDirty = (): void => {
93
+ state.setSavePhase(input.value.trim() === opts.value.trim() ? 'clean' : 'dirty');
94
+ };
95
+
96
+ body.append(label, input);
97
+ if (opts.tools) body.append(opts.tools(input, markDirty));
98
+ body.append(error);
99
+
100
+ // `commit` is what leaving edit mode runs: an action labelled "Save & exit"
101
+ // must write the draft in the box, not drop it. A refusal keeps the panel
102
+ // open (see `run`), and the error phase keeps edit mode open with it.
103
+ const claim = (): Interaction =>
104
+ state.begin({ kind: 'panel', close: () => close(false), commit: () => close(true) });
105
+
106
+ let token = claim();
107
+ let saving = false;
108
+
109
+ const teardown = (): void => {
110
+ state.releaseIf(token);
111
+ releaseFocus();
112
+ panel.remove();
113
+ backdrop.remove();
114
+ };
115
+
116
+ const setBusy = (busy: boolean): void => {
117
+ for (const btn of panel.querySelectorAll('button')) btn.disabled = busy;
118
+ input.readOnly = busy;
119
+ };
120
+
121
+ const run = async (): Promise<void> => {
122
+ saving = true;
123
+ setBusy(true);
124
+ error.toggleAttribute('data-on', false);
125
+
126
+ const failure = await opts.save(input.value);
127
+
128
+ saving = false;
129
+ if (!failure) {
130
+ teardown();
131
+ return;
132
+ }
133
+ // The in-flight `busy` interaction released the slot; re-claim it so the
134
+ // still-open panel keeps owning the page's clicks.
135
+ token = claim();
136
+ setBusy(false);
137
+ error.textContent = failure;
138
+ error.toggleAttribute('data-on', true);
139
+ input.focus();
140
+ };
141
+
142
+ const close = (commit: boolean): void => {
143
+ if (saving) return; // the write is in flight; let it settle
144
+ if (!commit || input.value.trim() === opts.value.trim()) {
145
+ teardown();
146
+ state.setSavePhase('clean');
147
+ return;
148
+ }
149
+ void run();
150
+ };
151
+
152
+ const backdrop = buildBackdrop(() => close(false));
153
+ wirePanelButtons(panel, () => close(false), () => close(true));
154
+
155
+ input.addEventListener('keydown', (e) => {
156
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
157
+ e.preventDefault();
158
+ close(true);
159
+ }
160
+ });
161
+ input.addEventListener('input', markDirty);
162
+
163
+ mount(backdrop, panel);
164
+ const releaseFocus = trapFocus(panel, { initial: input });
165
+ input.setSelectionRange(input.value.length, input.value.length);
166
+ }