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,105 @@
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 { COLOR, basename, lockElement, toast } from '../ui.ts';
6
+
7
+ /**
8
+ * Inline text editing: the clicked element becomes contenteditable in place.
9
+ * Enter/blur commits, Esc cancels (restoring the original exactly). Commits
10
+ * POST /apply; the server verifies the source still matches what the page
11
+ * showed before writing. (spec §5, §6.1)
12
+ */
13
+
14
+ export function beginTextEdit(el: HTMLElement, src: SourceLoc): void {
15
+ clearHighlight();
16
+ const original = el.textContent ?? '';
17
+
18
+ el.setAttribute('contenteditable', 'plaintext-only');
19
+ el.dataset.astroDevEditActive = '1';
20
+ el.style.outline = `2px solid ${COLOR.brand}`;
21
+ el.style.outlineOffset = '2px';
22
+ el.style.borderRadius = '2px';
23
+ el.focus();
24
+
25
+ // Select all so a full retype is one gesture.
26
+ const range = document.createRange();
27
+ range.selectNodeContents(el);
28
+ const sel = window.getSelection();
29
+ sel?.removeAllRanges();
30
+ sel?.addRange(range);
31
+
32
+ const finish = (commit: boolean): void => {
33
+ state.releaseIf(token);
34
+ el.removeEventListener('keydown', onKey);
35
+ el.removeEventListener('blur', onBlur);
36
+ el.removeEventListener('input', onInput);
37
+ el.removeAttribute('contenteditable');
38
+ delete el.dataset.astroDevEditActive;
39
+ el.style.outline = '';
40
+ el.style.outlineOffset = '';
41
+
42
+ const next = el.textContent ?? '';
43
+ if (!commit || next === original) {
44
+ el.textContent = original; // cancel / no-op restores exactly
45
+ state.setSavePhase('clean'); // nothing is pending — the bar can say so
46
+ return;
47
+ }
48
+ void commitTextEdit(el, src, original, next);
49
+ };
50
+ const token = state.begin({ kind: 'text', finish });
51
+
52
+ // Feeds the admin bar's exit button: unsaved keystrokes make it say
53
+ // "Save & exit". Typing the original text back is not a change.
54
+ const onInput = (): void => {
55
+ state.setSavePhase((el.textContent ?? '') === original ? 'clean' : 'dirty');
56
+ };
57
+
58
+ const onKey = (e: KeyboardEvent): void => {
59
+ if (e.key === 'Enter' && !e.shiftKey) {
60
+ e.preventDefault();
61
+ finish(true);
62
+ } else if (e.key === 'Escape') {
63
+ e.preventDefault();
64
+ finish(false);
65
+ }
66
+ };
67
+ const onBlur = (): void => finish(true);
68
+
69
+ el.addEventListener('keydown', onKey);
70
+ el.addEventListener('input', onInput);
71
+ el.addEventListener('blur', onBlur, { once: true });
72
+ }
73
+
74
+ async function commitTextEdit(
75
+ el: HTMLElement,
76
+ src: SourceLoc,
77
+ original: string,
78
+ newText: string,
79
+ ): Promise<void> {
80
+ // Hold the slot as busy while the save is in flight; releaseIf() means a
81
+ // click that already re-targeted (and began a new interaction) wins.
82
+ const busy = state.begin({ kind: 'busy' });
83
+ const release = lockElement(el);
84
+ state.setSavePhase('saving');
85
+ try {
86
+ await api.apply({
87
+ file: src.file,
88
+ loc: src.loc,
89
+ tag: el.tagName.toLowerCase(),
90
+ ops: [{ targetType: 'text', original, newText }],
91
+ });
92
+ state.setSavePhase('saved');
93
+ toast(`Saved — ${basename(src.file)}:${src.loc}`, 'ok');
94
+ // The file is written; Astro HMR reloads the page from disk.
95
+ } catch (err) {
96
+ el.textContent = original;
97
+ // The change was rolled back, so nothing is pending — but the failure must
98
+ // stay visible on the bar rather than reading as "all saved".
99
+ state.setSavePhase('error');
100
+ toast(`Save failed — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
101
+ } finally {
102
+ release();
103
+ state.releaseIf(busy);
104
+ }
105
+ }
@@ -0,0 +1,317 @@
1
+ import type {
2
+ MediaPick,
3
+ UnsplashImportWidth,
4
+ UnsplashOrientation,
5
+ UnsplashPhoto,
6
+ } from '../../shared/protocol.ts';
7
+ import {
8
+ UNSPLASH_IMPORT_WIDTHS,
9
+ coerceImportWidth,
10
+ importWidthLabel,
11
+ } from '../../shared/unsplash.ts';
12
+ import * as api from '../api.ts';
13
+ import { UnsplashError } from '../api.ts';
14
+ import { unsplashImportWidth } from '../features.ts';
15
+ import { icon } from '../icons.ts';
16
+ import { createSearchController, type SearchError, type SearchState } from '../unsplash-search.ts';
17
+ import { footButton, inputEl, styled, toast } from '../ui.ts';
18
+ import type { GridTile } from './media-grid.ts';
19
+ import {
20
+ railEmpty,
21
+ railLine,
22
+ railLink,
23
+ railPreview,
24
+ railTitle,
25
+ type MediaPane,
26
+ type MediaPaneDeps,
27
+ } from './media-modal.ts';
28
+ import { openSettingsPanel } from './settings-panel.ts';
29
+
30
+ /**
31
+ * The modal's Unsplash tab: search controls, results, and the per-tile import.
32
+ *
33
+ * All the sequencing — debounce, paging, discarding a superseded response —
34
+ * lives in the DOM-free `unsplash-search.ts` controller, which is unit-tested.
35
+ * This module is rendering plus the import call.
36
+ *
37
+ * Paging is a **Load more** button rather than infinite scroll, deliberately:
38
+ * the grid is a scroller inside a modal on a host page that may hijack `wheel`,
39
+ * and on a 50-requests-per-hour demo key an accidental fling must not burn five
40
+ * of them.
41
+ */
42
+
43
+ const ORIENTATIONS: Array<{ value: UnsplashOrientation; label: string }> = [
44
+ { value: 'any', label: 'Any shape' },
45
+ { value: 'landscape', label: 'Landscape' },
46
+ { value: 'portrait', label: 'Portrait' },
47
+ { value: 'squarish', label: 'Square' },
48
+ ];
49
+
50
+ /** Turn an api-layer failure into the controller's typed error. */
51
+ function toError(err: unknown): SearchError {
52
+ if (err instanceof UnsplashError) {
53
+ return { code: err.code, message: err.message, retryable: err.retryable };
54
+ }
55
+ return {
56
+ code: 'unknown',
57
+ message: err instanceof Error ? err.message : 'Search failed.',
58
+ retryable: true,
59
+ };
60
+ }
61
+
62
+ export function createUnsplashPane(deps: MediaPaneDeps): MediaPane {
63
+ let photos: UnsplashPhoto[] = [];
64
+ let remaining: number | undefined;
65
+ let importing: string | null = null;
66
+ // Starts at the resolved project-wide option and is then this pane's own
67
+ // choice, because the right size belongs to the slot the image goes in, not
68
+ // to the project. Not persisted: the next slot is a different size.
69
+ let width: UnsplashImportWidth = unsplashImportWidth();
70
+
71
+ // The pane contributes its toolbar only; the shared grid is placed by the
72
+ // shell (see MediaPane.el).
73
+ const el = styled('div', 'atx-media-toolbar atx-media-pane-unsplash');
74
+
75
+ // A div wearing the control baseline: the magnifier and the field sit inside
76
+ // one bordered box, so the box is the control and the <input> inside it is
77
+ // bare. inputEl() is typed to real form elements, hence the marker by hand.
78
+ const searchWrap = styled('div', 'atx-unsplash-search');
79
+ searchWrap.dataset.input = '';
80
+ const glass = icon('search', 16);
81
+ const searchInput = styled('input', 'atx-unsplash-input');
82
+ searchInput.type = 'search';
83
+ searchInput.placeholder = 'Search Unsplash…';
84
+ searchWrap.append(glass, searchInput);
85
+
86
+ const orientSelect = inputEl('select', 'atx-unsplash-orient');
87
+ for (const { value, label } of ORIENTATIONS) {
88
+ const option = document.createElement('option');
89
+ option.value = value;
90
+ option.textContent = label;
91
+ orientSelect.append(option);
92
+ }
93
+
94
+ // Width, next to shape: both narrow what a pick will produce, and both are
95
+ // the pane's own state rather than the modal's.
96
+ const widthSelect = inputEl('select', 'atx-unsplash-width');
97
+ for (const value of UNSPLASH_IMPORT_WIDTHS) {
98
+ const option = document.createElement('option');
99
+ option.value = String(value);
100
+ option.textContent = importWidthLabel(value);
101
+ widthSelect.append(option);
102
+ }
103
+ widthSelect.value = String(width);
104
+ widthSelect.title = 'Width the chosen photo is downloaded at';
105
+ widthSelect.addEventListener('change', () => {
106
+ width = coerceImportWidth(widthSelect.value) ?? width;
107
+ deps.refresh(); // the rail states the width, so it repaints with it
108
+ });
109
+
110
+ el.append(searchWrap, orientSelect, widthSelect);
111
+
112
+ // --- the controller --------------------------------------------------------
113
+ const controller = createSearchController({
114
+ search: (req) => api.unsplashSearch(req),
115
+ onState: (next) => render(next),
116
+ toError,
117
+ });
118
+
119
+ searchInput.addEventListener('input', () => controller.setQuery(searchInput.value));
120
+ orientSelect.addEventListener('change', () =>
121
+ controller.setOrientation(orientSelect.value as UnsplashOrientation),
122
+ );
123
+
124
+ const loadMoreBtn = footButton('Load more', 'outline', () => controller.loadMore());
125
+
126
+ function render(next: SearchState): void {
127
+ deps.grid.footer.textContent = '';
128
+
129
+ if (next.status === 'idle') {
130
+ photos = [];
131
+ deps.grid.showMessage('Type to search Unsplash. Nothing is requested until you stop typing.');
132
+ deps.refresh();
133
+ return;
134
+ }
135
+ if (next.status === 'loading') {
136
+ photos = [];
137
+ deps.grid.showSkeletons();
138
+ deps.refresh();
139
+ return;
140
+ }
141
+ if (next.status === 'empty') {
142
+ photos = [];
143
+ deps.grid.showMessage(`No photos match “${next.query}”.`);
144
+ deps.refresh();
145
+ return;
146
+ }
147
+ if (next.status === 'error') {
148
+ photos = [];
149
+ renderError(next.error);
150
+ deps.refresh();
151
+ return;
152
+ }
153
+
154
+ photos = next.photos;
155
+ remaining = next.remaining;
156
+ deps.grid.setTiles(
157
+ next.photos.map(
158
+ (photo): GridTile => ({
159
+ key: photo.id,
160
+ thumbUrl: photo.thumbUrl,
161
+ color: photo.color,
162
+ label: photo.description || `Photo by ${photo.photographer}`,
163
+ caption: {
164
+ kind: 'credit',
165
+ photographer: photo.photographer,
166
+ photographerUrl: photo.photographerUrl,
167
+ pageUrl: photo.pageUrl,
168
+ },
169
+ }),
170
+ ),
171
+ );
172
+
173
+ if (next.page < next.totalPages) {
174
+ loadMoreBtn.textContent = next.loadingMore
175
+ ? 'Loading…'
176
+ : `Load more (${next.photos.length} of ${next.total.toLocaleString()})`;
177
+ loadMoreBtn.disabled = next.loadingMore;
178
+ deps.grid.footer.append(loadMoreBtn);
179
+ }
180
+ if (next.moreError) {
181
+ const line = styled('span', 'atx-media-error atx-media-error-more');
182
+ line.textContent = next.moreError.message;
183
+ deps.grid.footer.append(line);
184
+ }
185
+ deps.refresh();
186
+ }
187
+
188
+ /** An unconfigured key gets a card with a way out, not an error string. */
189
+ function renderError(error: SearchError): void {
190
+ if (error.code === 'unconfigured' || error.code === 'disabled') {
191
+ deps.grid.showMessage('');
192
+ const box = deps.grid.el.querySelector('.atx-media-status') as HTMLElement;
193
+ box.textContent = '';
194
+ const title = styled('p', 'atx-media-error atx-media-error-title');
195
+ title.textContent = 'Add an Unsplash access key';
196
+ const detail = styled('p', 'atx-media-error atx-media-error-detail');
197
+ detail.textContent = error.message;
198
+ // Opened above the modal (which is Z_MODAL+8), and re-runs on close
199
+ // so entering a key here lands you straight back in results.
200
+ const open = footButton('Open Settings', 'default', () =>
201
+ // Straight to the Unsplash tab: the user clicked a card about a
202
+ // missing key, so landing them on General would be a detour.
203
+ openSettingsPanel({ tab: 'unsplash', layer: 10, onClose: () => controller.retry() }),
204
+ );
205
+ open.classList.add('atx-media-error-action');
206
+ box.append(title, detail, open);
207
+ return;
208
+ }
209
+ deps.grid.showMessage(
210
+ error.message,
211
+ error.retryable ? () => controller.retry() : undefined,
212
+ );
213
+ }
214
+
215
+ const photoFor = (id: string): UnsplashPhoto | undefined => photos.find((p) => p.id === id);
216
+
217
+ return {
218
+ el,
219
+ commitLabel: 'Import & use',
220
+
221
+ activate() {
222
+ searchInput.focus();
223
+ render(controller.state());
224
+ },
225
+
226
+ status() {
227
+ const at = controller.state();
228
+ if (at.status === 'ready') {
229
+ const shown = at.photos.length;
230
+ return `${shown} of ${at.total.toLocaleString()} photos`;
231
+ }
232
+ if (at.status === 'loading') return 'Searching…';
233
+ return '';
234
+ },
235
+
236
+ renderRail(into, key) {
237
+ const photo = key === null ? undefined : photoFor(key);
238
+ if (!photo) {
239
+ into.append(railEmpty('Search, then select a photo to see its details.'));
240
+ } else {
241
+ into.append(
242
+ railPreview(photo.thumbUrl, photo.color),
243
+ railTitle(photo.description || `Photo by ${photo.photographer}`),
244
+ railLink('Photographer', photo.photographer, photo.photographerUrl),
245
+ railLink('Source', 'View on Unsplash', photo.pageUrl),
246
+ railLine('Dimensions', `${photo.width} × ${photo.height}`),
247
+ railLine('Downloads at', downloadsAt(photo, width)),
248
+ railLine('Saves as', importFilename(photo)),
249
+ );
250
+ }
251
+ if (remaining !== undefined) {
252
+ const line = styled('p', 'atx-unsplash-rate');
253
+ // The last few requests of the hour are worth noticing.
254
+ if (remaining <= 5) line.dataset.tone = 'warn';
255
+ line.textContent = `${remaining} Unsplash requests left this hour`;
256
+ into.append(line);
257
+ }
258
+ },
259
+
260
+ async commit(key) {
261
+ if (importing) return null;
262
+ importing = key;
263
+ // Per-tile busy, never a global lock — see the media-modal header.
264
+ deps.grid.setTileBusy(key, true);
265
+ try {
266
+ const res = await api.unsplashImport({
267
+ id: key,
268
+ width,
269
+ ...(deps.assetRef ? { assetRef: deps.assetRef } : {}),
270
+ ...(deps.targetDir ? { targetDir: deps.targetDir } : {}),
271
+ });
272
+ toast(`Imported ${res.filename}`, 'ok');
273
+ return { webPath: res.webPath, origin: 'unsplash' } satisfies MediaPick;
274
+ } catch (err) {
275
+ const mapped = toError(err);
276
+ toast(`Import failed — ${mapped.message}`, 'err');
277
+ // An expired id means the dev server restarted since the search; the
278
+ // results on screen are all stale, so re-run rather than leave them.
279
+ if (mapped.code === 'expired') controller.retry();
280
+ return null;
281
+ } finally {
282
+ importing = null;
283
+ deps.grid.setTileBusy(key, false);
284
+ }
285
+ },
286
+
287
+ dispose() {
288
+ controller.dispose();
289
+ },
290
+ };
291
+ }
292
+
293
+ /** What the chosen width actually means for this photo. `fit=max` only shrinks,
294
+ * so a photo narrower than the request comes back at its own size — saying
295
+ * "2400 px" there would be a promise the CDN does not keep. */
296
+ function downloadsAt(photo: UnsplashPhoto, width: UnsplashImportWidth): string {
297
+ if (width === 'original') return `${photo.width} px wide (original)`;
298
+ if (photo.width <= width) return `${photo.width} px wide (already smaller)`;
299
+ return `${width} px wide`;
300
+ }
301
+
302
+ /** Mirrors the server's naming so the rail can promise what will land. The
303
+ * server re-derives it as the authority; this is a preview, not a request. */
304
+ function importFilename(photo: UnsplashPhoto): string {
305
+ const stem = slugPreview(photo.description || photo.photographer || 'photo');
306
+ return `unsplash-${stem || 'photo'}-${slugPreview(photo.id)}.jpg`;
307
+ }
308
+
309
+ function slugPreview(raw: string): string {
310
+ return raw
311
+ .toLowerCase()
312
+ .normalize('NFKD')
313
+ .replace(/[̀-ͯ]/g, '')
314
+ .replace(/[^a-z0-9]+/g, '-')
315
+ .replace(/^-+|-+$/g, '')
316
+ .slice(0, 120);
317
+ }