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,370 @@
1
+ import type {
2
+ ApplyRequestWire,
3
+ AssetsResponse,
4
+ ClassifyRequest,
5
+ ClassifyResult,
6
+ CollectionApplyResponse,
7
+ CollectionCreateRequest,
8
+ CollectionCreateResponse,
9
+ CollectionEntriesRequest,
10
+ CollectionEntriesResponse,
11
+ CollectionOpenRequest,
12
+ CollectionPageEditingRequest,
13
+ CollectionPageEditingResponse,
14
+ CollectionSchemaApplyRequest,
15
+ CollectionsResponse,
16
+ EntryApplyRequest,
17
+ EntryCreateRequest,
18
+ EntryCreateResponse,
19
+ EntryDeleteRequest,
20
+ EntryErrorResponse,
21
+ EntryRequest,
22
+ EntryResponse,
23
+ HealthResponse,
24
+ InspectOpenRequest,
25
+ InspectOpenResponse,
26
+ OpenRequest,
27
+ EntryResolveRequest,
28
+ EntryResolveResponse,
29
+ PageSourceRequest,
30
+ PageSourceResponse,
31
+ PeekRequest,
32
+ PeekResponse,
33
+ SettingsErrorResponse,
34
+ SettingsResponse,
35
+ SettingsUpdateRequest,
36
+ UnsplashErrorCode,
37
+ UnsplashErrorResponse,
38
+ UnsplashImportRequest,
39
+ UnsplashImportResponse,
40
+ UnsplashSearchRequest,
41
+ UnsplashSearchResponse,
42
+ UploadRequest,
43
+ UploadResponse,
44
+ } from '../shared/protocol.ts';
45
+
46
+ /**
47
+ * Typed fetch client for the /__dev-edit endpoints — the only place the
48
+ * overlay talks to the dev server. Pure I/O: no DOM, no toasts; callers
49
+ * present errors. Every function throws the server's `error` message (or a
50
+ * "<what> failed (<status>)" fallback) on a non-OK response.
51
+ */
52
+
53
+ const API = '/__dev-edit';
54
+
55
+ async function errorMessage(res: Response): Promise<string | undefined> {
56
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
57
+ return body.error;
58
+ }
59
+
60
+ async function post(path: string, payload: unknown): Promise<Response> {
61
+ return fetch(`${API}${path}`, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify(payload),
65
+ });
66
+ }
67
+
68
+ /** The server's health payload (config flags the overlay reads at boot), or
69
+ * null when the server side isn't alive — the overlay stays out of the way. */
70
+ export async function health(): Promise<HealthResponse | null> {
71
+ try {
72
+ const res = await fetch(`${API}/health`);
73
+ if (!res.ok) return null;
74
+ return (await res.json()) as HealthResponse;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /** List the project's swap-candidate images, with size, mtime and whether a
81
+ * build still serves them — plus the public dir the last of those is measured
82
+ * against, so a picker can name it when it refuses a file. */
83
+ export async function getAssets(): Promise<AssetsResponse> {
84
+ const res = await fetch(`${API}/assets`);
85
+ if (!res.ok) throw new Error(`server returned ${res.status}`);
86
+ const ct = res.headers.get('content-type') ?? '';
87
+ if (!ct.includes('application/json')) {
88
+ // Most likely our middleware didn't handle the route and Vite served
89
+ // HTML — tells us exactly what went wrong instead of a vague message.
90
+ throw new Error('endpoint returned non-JSON (middleware not reached?)');
91
+ }
92
+ const body = (await res.json()) as AssetsResponse;
93
+ // A server that predates `servable` listed nothing but web-path candidates,
94
+ // so treating an absent flag as true keeps such a listing usable.
95
+ return {
96
+ files: body.files.map((f) => ({ ...f, servable: f.servable ?? true })),
97
+ publicDir: body.publicDir || 'public',
98
+ };
99
+ }
100
+
101
+ /** Upload an image (as a data URL); returns its web-servable path. */
102
+ export async function upload(req: UploadRequest): Promise<UploadResponse> {
103
+ const res = await post('/upload', req);
104
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `upload failed (${res.status})`);
105
+ return (await res.json()) as UploadResponse;
106
+ }
107
+
108
+ /** Open a source location in the user's editor. */
109
+ export async function open(req: OpenRequest): Promise<void> {
110
+ const res = await post('/open', req);
111
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `open failed (${res.status})`);
112
+ }
113
+
114
+ /** Which source file the route serving `pathname` is written in — the admin
115
+ * bar's *Open page source*. Answered from Astro's route manifest, so it is the
116
+ * page's own template rather than whichever component filled the most of the
117
+ * DOM; `refusal` set means nothing could be identified. Named apart from
118
+ * `page-source.ts::pageSource`, which reads the backing-content `<meta>`. */
119
+ export async function resolvePageSource(req: PageSourceRequest): Promise<PageSourceResponse> {
120
+ const res = await post('/page-source', req);
121
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `page-source failed (${res.status})`);
122
+ return (await res.json()) as PageSourceResponse;
123
+ }
124
+
125
+ /** Which content entry backs `pathname`, when the tool can tell. What replaces
126
+ * hand-emitting the page-source meta tag; `refusal` set means no entry, and
127
+ * `not-enabled` additionally names the collection whose switch is off. */
128
+ export async function resolveEntry(req: EntryResolveRequest): Promise<EntryResolveResponse> {
129
+ const res = await post('/entry/resolve', req);
130
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `entry resolve failed (${res.status})`);
131
+ return (await res.json()) as EntryResolveResponse;
132
+ }
133
+
134
+ /** Open a CSS rule's source in the editor: the server best-effort locates the
135
+ * selector and jumps there (or to the file top). */
136
+ export async function inspectOpen(req: InspectOpenRequest): Promise<InspectOpenResponse> {
137
+ const res = await post('/inspect/open', req);
138
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `open failed (${res.status})`);
139
+ return (await res.json()) as InspectOpenResponse;
140
+ }
141
+
142
+ /** Read-only window of source lines around a loc, for the in-browser peek. */
143
+ export async function peek(req: PeekRequest): Promise<PeekResponse> {
144
+ const res = await post('/peek', req);
145
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `peek failed (${res.status})`);
146
+ return (await res.json()) as PeekResponse;
147
+ }
148
+
149
+ /** AST-truth classification of the clicked element. (spec §7.3, §16.1) */
150
+ export async function classify(req: ClassifyRequest): Promise<ClassifyResult> {
151
+ const res = await post('/classify', req);
152
+ const body = (await res.json().catch(() => ({}))) as ClassifyResult & { error?: string };
153
+ if (!res.ok) throw new Error(body.error ?? `classify failed (${res.status})`);
154
+ return body;
155
+ }
156
+
157
+ /** The real save: the server re-resolves the element in the AST, verifies the
158
+ * source still matches `original`, and writes atomically. (spec §5, §7.5) */
159
+ export async function apply(req: ApplyRequestWire): Promise<void> {
160
+ const res = await post('/apply', req);
161
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `save failed (${res.status})`);
162
+ }
163
+
164
+ /** Probe a page route (NOT a /__dev-edit endpoint): true once the dev server
165
+ * answers it with something other than a 404. Used after entry create to
166
+ * wait out the content-layer sync before navigating to the new page. */
167
+ export async function routeExists(url: string): Promise<boolean> {
168
+ try {
169
+ const res = await fetch(url, { cache: 'no-store' });
170
+ return res.status !== 404;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ // --- Entry editor ------------------------------------------------------------
177
+
178
+ /** Entry-endpoint failure carrying the code and per-field validation messages
179
+ * the panel needs for inline rendering — richer than the string-only errors
180
+ * the loc-based endpoints get away with. */
181
+ export class EntryApplyError extends Error {
182
+ code?: EntryErrorResponse['code'];
183
+ fieldErrors?: Record<string, string>;
184
+ constructor(body: EntryErrorResponse, status: number) {
185
+ super(body.error || `request failed (${status})`);
186
+ this.name = 'EntryApplyError';
187
+ this.code = body.code;
188
+ this.fieldErrors = body.fieldErrors;
189
+ }
190
+ }
191
+
192
+ async function entryPost<T>(path: string, payload: unknown, what: string): Promise<T> {
193
+ const res = await post(path, payload);
194
+ const body = (await res.json().catch(() => ({ error: `${what} failed (${res.status})` }))) as
195
+ | T
196
+ | EntryErrorResponse;
197
+ if (!res.ok) throw new EntryApplyError(body as EntryErrorResponse, res.status);
198
+ return body as T;
199
+ }
200
+
201
+ /** Read a collection entry as typed fields + markdown body. */
202
+ export async function getEntry(req: EntryRequest): Promise<EntryResponse> {
203
+ return entryPost<EntryResponse>('/entry', req, 'entry read');
204
+ }
205
+
206
+ /** Atomic multi-field save; throws EntryApplyError on conflict/validation. */
207
+ export async function applyEntry(req: EntryApplyRequest): Promise<void> {
208
+ await entryPost<{ ok: true }>('/entry/apply', req, 'save');
209
+ }
210
+
211
+ /** Create a new entry in a collection; resolves to its repo-relative path. */
212
+ export async function createEntry(req: EntryCreateRequest): Promise<EntryCreateResponse> {
213
+ return entryPost<EntryCreateResponse>('/entry/create', req, 'create');
214
+ }
215
+
216
+ /** Delete an entry (etag-guarded; undo is git). */
217
+ export async function deleteEntry(req: EntryDeleteRequest): Promise<void> {
218
+ await entryPost<{ ok: true }>('/entry/delete', req, 'delete');
219
+ }
220
+
221
+ // --- Unsplash + settings -----------------------------------------------------
222
+
223
+ /** Unsplash-endpoint failure carrying the server's `code`, which is what the
224
+ * pane branches on: a bad key or a disabled feature needs a settings change,
225
+ * while a timeout or an upstream fault is worth a Retry button. Mirrors the
226
+ * EntryApplyError/entryPost pair. */
227
+ export class UnsplashError extends Error {
228
+ code: UnsplashErrorCode | 'unknown';
229
+ constructor(body: Partial<UnsplashErrorResponse>, status: number) {
230
+ super(body.error || `Unsplash request failed (${status})`);
231
+ this.name = 'UnsplashError';
232
+ this.code = body.code ?? 'unknown';
233
+ }
234
+ /** Whether offering a Retry makes sense. Configuration faults do not fix
235
+ * themselves, so the pane shows a link to Settings instead. */
236
+ get retryable(): boolean {
237
+ return !['disabled', 'unconfigured', 'unauthorized', 'expired'].includes(this.code);
238
+ }
239
+ }
240
+
241
+ async function unsplashPost<T>(path: string, payload: unknown): Promise<T> {
242
+ const res = await post(path, payload);
243
+ const body = (await res.json().catch(() => ({}))) as T | UnsplashErrorResponse;
244
+ if (!res.ok) throw new UnsplashError(body as UnsplashErrorResponse, res.status);
245
+ return body as T;
246
+ }
247
+
248
+ /** Search Unsplash through the dev server, which holds the key and reshapes
249
+ * every photo. Throws UnsplashError. */
250
+ export async function unsplashSearch(
251
+ req: UnsplashSearchRequest,
252
+ ): Promise<UnsplashSearchResponse> {
253
+ return unsplashPost<UnsplashSearchResponse>('/unsplash/search', req);
254
+ }
255
+
256
+ /** Download a searched photo into the project. Throws UnsplashError — notably
257
+ * `expired` when the dev server restarted since the search. */
258
+ export async function unsplashImport(
259
+ req: UnsplashImportRequest,
260
+ ): Promise<UnsplashImportResponse> {
261
+ return unsplashPost<UnsplashImportResponse>('/unsplash/import', req);
262
+ }
263
+
264
+ /** Read every integration option plus the access-key status. Never returns the
265
+ * key itself — only whether one resolved, from where, and a masked hint. */
266
+ export async function getSettings(): Promise<SettingsResponse> {
267
+ const res = await fetch(`${API}/settings`);
268
+ if (!res.ok) throw new Error((await errorMessage(res)) ?? `settings failed (${res.status})`);
269
+ return (await res.json()) as SettingsResponse;
270
+ }
271
+
272
+ /**
273
+ * Save a sparse option patch and/or the Unsplash access key. Resolves to the
274
+ * same shape a read would, so the panel needs no follow-up request.
275
+ *
276
+ * A 422 carries per-option messages, so the rejection is thrown as a
277
+ * {@link SettingsRefusal} the drawer can paint onto individual controls rather
278
+ * than as a single opaque message. Nothing was written when this throws — the
279
+ * server refuses a patch whole.
280
+ */
281
+ export async function saveSettings(req: SettingsUpdateRequest): Promise<SettingsResponse> {
282
+ const res = await post('/settings', req);
283
+ if (res.ok) return (await res.json()) as SettingsResponse;
284
+
285
+ const body = (await res.json().catch(() => null)) as SettingsErrorResponse | null;
286
+ if (body?.fieldErrors) {
287
+ throw new SettingsRefusal(body.error || 'some options were refused', body.fieldErrors);
288
+ }
289
+ throw new Error(body?.error ?? `settings save failed (${res.status})`);
290
+ }
291
+
292
+ /** A refusal that names the options at fault. */
293
+ export class SettingsRefusal extends Error {
294
+ constructor(
295
+ message: string,
296
+ readonly fieldErrors: Record<string, string>,
297
+ ) {
298
+ super(message);
299
+ this.name = 'SettingsRefusal';
300
+ }
301
+ }
302
+
303
+ /** A collection-designer refusal, carrying the server's code so the panel can
304
+ * tell "reopen the tab" (a conflict) from "this shape can't be patched". */
305
+ export class CollectionRefusalError extends Error {
306
+ constructor(
307
+ message: string,
308
+ readonly code: string | undefined,
309
+ readonly status: number,
310
+ ) {
311
+ super(message);
312
+ this.name = 'CollectionRefusalError';
313
+ }
314
+ }
315
+
316
+ async function collectionPost<T>(path: string, payload: unknown): Promise<T> {
317
+ const res = await post(path, payload);
318
+ const body = (await res.json().catch(() => ({}))) as T & { error?: string; code?: string };
319
+ if (!res.ok) {
320
+ throw new CollectionRefusalError(
321
+ body.error ?? `${path} failed (${res.status})`,
322
+ body.code,
323
+ res.status,
324
+ );
325
+ }
326
+ return body;
327
+ }
328
+
329
+ /** Every collection, its fields, and the content-config etag every write needs. */
330
+ export async function listCollections(): Promise<CollectionsResponse> {
331
+ return collectionPost<CollectionsResponse>('/collections', {});
332
+ }
333
+
334
+ /** Schema edits and/or editor overrides for one collection. The schema half is
335
+ * etag-guarded and all-or-nothing; the response says which half landed. */
336
+ export async function applyCollectionSchema(
337
+ req: CollectionSchemaApplyRequest,
338
+ ): Promise<CollectionApplyResponse> {
339
+ return collectionPost<CollectionApplyResponse>('/collection/schema/apply', req);
340
+ }
341
+
342
+ /** One collection's entry files, newest first. Reaches drafts and entries no
343
+ * rendered page links to — which is the point of the Items view. */
344
+ export async function listCollectionEntries(
345
+ req: CollectionEntriesRequest,
346
+ ): Promise<CollectionEntriesResponse> {
347
+ return collectionPost<CollectionEntriesResponse>('/collection/entries', req);
348
+ }
349
+
350
+ /** Launch the editor on the content config, at a collection's own line when one
351
+ * is named. Carries no path — the server opens the config it discovered. */
352
+ export async function openCollectionSource(req: CollectionOpenRequest): Promise<void> {
353
+ await collectionPost<{ ok: true }>('/collection/open', req);
354
+ }
355
+
356
+ /** Switch one collection's in-page entry drawer on or off. Saves on the flip —
357
+ * the list view it is drawn in has no Save button — and refuses when
358
+ * `astro.config.mjs` owns the flag. */
359
+ export async function setCollectionPageEditing(
360
+ req: CollectionPageEditingRequest,
361
+ ): Promise<CollectionPageEditingResponse> {
362
+ return collectionPost<CollectionPageEditingResponse>('/collection/page-editing', req);
363
+ }
364
+
365
+ /** Append a collection to the content config and make its entry directory. */
366
+ export async function createCollection(
367
+ req: CollectionCreateRequest,
368
+ ): Promise<CollectionCreateResponse> {
369
+ return collectionPost<CollectionCreateResponse>('/collection/create', req);
370
+ }
@@ -0,0 +1,61 @@
1
+ import type { ClassifyRequest, ClassifyResult } from '../shared/protocol.ts';
2
+ import * as api from './api.ts';
3
+
4
+ /**
5
+ * Session cache for /classify verdicts, keyed by file|loc|tag. Hover
6
+ * verification is the heavy caller: every cache miss costs the server a file
7
+ * read plus a full AST parse, and hovering generates far more lookups than
8
+ * clicking ever will. So settled verdicts are kept until the next HMR update
9
+ * rewrites a source file, and concurrent requests for the same key share one
10
+ * round-trip. Failures are never cached — the next lookup retries.
11
+ */
12
+
13
+ type Fetcher = (req: ClassifyRequest) => Promise<ClassifyResult>;
14
+
15
+ const settled = new Map<string, ClassifyResult>();
16
+ const inflight = new Map<string, Promise<ClassifyResult>>();
17
+ // Bumped by invalidation so a request that was already in flight when the
18
+ // source changed can tell its verdict is stale and must not be cached.
19
+ let generation = 0;
20
+
21
+ const keyOf = (req: ClassifyRequest): string => `${req.file}|${req.loc}|${req.tag}`;
22
+
23
+ /** The settled verdict for this element, if one is cached — never fetches. */
24
+ export function peekClassification(req: ClassifyRequest): ClassifyResult | undefined {
25
+ return settled.get(keyOf(req));
26
+ }
27
+
28
+ /** Classify through the cache: settled verdicts return without a round-trip,
29
+ * concurrent misses for the same key share one request. */
30
+ export function classifyCached(
31
+ req: ClassifyRequest,
32
+ fetcher: Fetcher = api.classify,
33
+ ): Promise<ClassifyResult> {
34
+ const key = keyOf(req);
35
+ const hit = settled.get(key);
36
+ if (hit) return Promise.resolve(hit);
37
+ const pending = inflight.get(key);
38
+ if (pending) return pending;
39
+
40
+ const gen = generation;
41
+ const request = (async () => {
42
+ const result = await fetcher(req);
43
+ if (gen === generation) settled.set(key, result);
44
+ return result;
45
+ })();
46
+ // Drop the in-flight slot once it settles (unless invalidation already
47
+ // cleared it and a newer request took the key).
48
+ const cleanup = (): void => {
49
+ if (inflight.get(key) === request) inflight.delete(key);
50
+ };
51
+ request.then(cleanup, cleanup);
52
+ inflight.set(key, request);
53
+ return request;
54
+ }
55
+
56
+ /** Forget every verdict — call when HMR rewrites source files. */
57
+ export function invalidateClassifications(): void {
58
+ generation++;
59
+ settled.clear();
60
+ inflight.clear();
61
+ }