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,935 @@
1
+ /**
2
+ * Wire protocol for the /__dev-edit endpoints — the single source of truth
3
+ * for every request/response shape exchanged between the browser overlay and
4
+ * the dev-server middleware.
5
+ *
6
+ * Types only, no runtime code: both sides import from here with `import type`
7
+ * (enforced by verbatimModuleSyntax), so this file can never reach the client
8
+ * bundle. If a future feature changes what one side sends, the other side
9
+ * fails `npm run typecheck` instead of failing at runtime.
10
+ */
11
+
12
+ /** A source location as captured from data-astro-source-file / -loc. */
13
+ export interface SourceLoc {
14
+ /** Path to the source file, relative to the project root. */
15
+ file: string;
16
+ /** "line:col" as emitted by Astro's dev annotations. */
17
+ loc: string;
18
+ }
19
+
20
+ /** What an edit targets: the element's text content, its inner source when
21
+ * that text carries inline markup, the frontmatter string an `{expression}`
22
+ * renders, or an img attribute. */
23
+ export type TargetType = 'text' | 'markup' | 'expression' | 'src' | 'alt';
24
+
25
+ /** Whether an attribute can be patched: statically quoted, expression-driven,
26
+ * or absent from the source. */
27
+ export type AttrState = 'static' | 'dynamic' | 'missing';
28
+
29
+ export type ClassifyKind =
30
+ | 'text' // children are exclusively literal text → editable inline
31
+ | 'markup' // literal text plus safelisted inline tags → editable as raw source
32
+ | 'expression' // {expression} traced to a frontmatter string → editable by value
33
+ | 'image' // an element whose src/alt attrs may be editable (see attrs)
34
+ | 'empty' // no children; nothing to text-edit
35
+ | 'dynamic' // expression / child elements / component content
36
+ | 'ambiguous' // ≥2 elements share this annotation loc — cannot patch safely
37
+ | 'unresolved'; // no element matches this loc (stale DOM, edited file)
38
+
39
+ /** Why an apply was refused. Also used as ApplyResult codes server-side. */
40
+ export type RefusalCode = 'dynamic' | 'mismatch' | 'unresolved' | 'ambiguous' | 'unsupported';
41
+
42
+ // --- GET /health -------------------------------------------------------------
43
+ export interface HealthResponse {
44
+ ok: true;
45
+ name: string;
46
+ milestone: number;
47
+ /** Whether the hover-pill CSS class/ID inspector is enabled. The overlay
48
+ * reads this at boot and skips rendering the chips row when false. */
49
+ cssInspector: boolean;
50
+ /** Whether the "Open source" buttons and jump-to-file links should render.
51
+ * Absent from a server that predates the option editor. */
52
+ openInEditor?: boolean;
53
+ /** Whether the CMS entry drawer is enabled. Absent from a server that
54
+ * predates the option editor. */
55
+ entryEditor?: boolean;
56
+ /** Absolute project root. Astro's source annotations are absolute fsPaths,
57
+ * which the overlay only ever showed a basename of; the copied element
58
+ * context needs them repo-relative to be worth pasting anywhere, and this
59
+ * is the only way the client can strip the prefix exactly. Dev-only, and
60
+ * the annotations already carry the same information. */
61
+ root: string;
62
+ /** Whether the Unsplash photo source is both enabled AND holds a usable key.
63
+ * The overlay reads this at boot to decide whether the media modal renders
64
+ * its source tabs at all — a project that never opts in gets a single-source
65
+ * modal, not a tab that errors when clicked. */
66
+ unsplash: boolean;
67
+ /** The resolved `unsplash.importWidth` — where the picker's size select
68
+ * starts, so the project-wide setting is what you get unless you change it
69
+ * for one import. Absent from a server that predates the option. */
70
+ unsplashImportWidth?: UnsplashImportWidth;
71
+ }
72
+
73
+ // --- GET /assets -------------------------------------------------------------
74
+ /** One image under the configured asset dirs. Metadata rather than a bare path
75
+ * so the picker can sort by recency and caption a tile — an image uploaded a
76
+ * minute ago is otherwise buried in an alphabetical list. */
77
+ export interface AssetInfo {
78
+ /** Web-servable path, e.g. `/src/assets/hero.jpg`. */
79
+ path: string;
80
+ /** Size in bytes. */
81
+ size: number;
82
+ /** Last-modified time, epoch ms — the sort key behind "Newest first". */
83
+ mtime: number;
84
+ /**
85
+ * Whether a **production build** still serves the file at {@link path}.
86
+ *
87
+ * The asset dirs span two worlds on purpose — `src/assets` has to be listed
88
+ * so `image()` fields have somewhere to browse — but only the project's
89
+ * `publicDir` is copied into the output. `/src/assets/hero.svg` is a truthful
90
+ * *dev* URL and a 404 in the built site, so a picker filling a plain
91
+ * `<img src>` or a markdown destination has to refuse it at pick time. The
92
+ * server decides this from the configured public dir; no client re-derives it
93
+ * from the path.
94
+ */
95
+ servable: boolean;
96
+ }
97
+ export interface AssetsResponse {
98
+ /** Images under the configured asset dirs, sorted by path. */
99
+ files: AssetInfo[];
100
+ /** The project's public directory, root-relative — what a picker names when
101
+ * it explains why a non-servable file cannot be used. */
102
+ publicDir: string;
103
+ }
104
+
105
+ // --- POST /upload ------------------------------------------------------------
106
+ export interface UploadRequest {
107
+ /** data: URL of the image to write. */
108
+ dataUrl: string;
109
+ /** Client-suggested filename; sanitised server-side. */
110
+ filename: string;
111
+ /**
112
+ * Marks the upload as backing an `assetRef: 'relative'` image field. Such
113
+ * assets are imported by Astro rather than served verbatim, so the write
114
+ * targets an importable `src/` dir and animated formats the image optimiser
115
+ * would flatten are refused.
116
+ */
117
+ assetRef?: 'relative';
118
+ /**
119
+ * Root-relative directory to write into, so an upload lands beside the
120
+ * field's existing asset instead of a shared root. Confined server-side to
121
+ * the configured asset directories; omitted or rejected falls back to
122
+ * `imageUploadDir` (relative fields) or `uploadDir`.
123
+ */
124
+ targetDir?: string;
125
+ }
126
+ export interface UploadResponse {
127
+ webPath: string;
128
+ }
129
+
130
+ // --- POST /open --------------------------------------------------------------
131
+ export interface OpenRequest {
132
+ file: string;
133
+ /** "line:col"; omitted or empty opens the file at its top. */
134
+ loc?: string;
135
+ }
136
+
137
+ // --- POST /page-source -------------------------------------------------------
138
+ /**
139
+ * "Which file is this route written in?" — the admin bar's *Open page source*.
140
+ *
141
+ * Answered from Astro's own route manifest, because the DOM cannot answer it:
142
+ * component tags are never annotated, so counting annotated elements makes a
143
+ * markup-dense Nav.astro outrank a page that merely composes components.
144
+ */
145
+ export interface PageSourceRequest {
146
+ /** `location.pathname` exactly as the browser has it, base prefix included —
147
+ * the server strips the configured base itself. */
148
+ pathname: string;
149
+ }
150
+ /** Why nothing resolved. `no-routes` — the manifest is empty (the routes hook
151
+ * never fired); `no-match` — no page route matches the pathname; `not-in-project`
152
+ * — the matched route's entrypoint belongs to a package or sits outside the
153
+ * root; `missing` — the entrypoint is not on disk (Astro's injected default
154
+ * 404 page). */
155
+ export type PageSourceRefusal = 'no-routes' | 'no-match' | 'not-in-project' | 'missing';
156
+ export interface PageSourceResponse {
157
+ /** Root-relative path of the route's entrypoint, or null when unresolved. */
158
+ file: string | null;
159
+ /** The route pattern that matched, e.g. "/articles/[...slug]"; null when
160
+ * unresolved. */
161
+ pattern: string | null;
162
+ /** Null on success. Set means nothing was opened — the panel says so rather
163
+ * than guessing at a file. */
164
+ refusal: PageSourceRefusal | null;
165
+ }
166
+
167
+ // --- POST /inspect/open ------------------------------------------------------
168
+ /** Open the source of a CSS rule in the editor. The client resolves `file`
169
+ * from the stylesheet URL; the server best-effort locates `selector` inside it
170
+ * (for .astro, only within <style> blocks) and launches the editor there. */
171
+ export interface InspectOpenRequest {
172
+ /** Source file the rule came from — root-relative or absolute. */
173
+ file: string;
174
+ /** The class/id selector fragment to locate, e.g. ".hero-title" or "#masthead". */
175
+ selector: string;
176
+ }
177
+ export interface InspectOpenResponse {
178
+ ok: true;
179
+ /** "line:col" where the selector was found, or null when it wasn't (the file
180
+ * was opened at its top instead). */
181
+ loc: string | null;
182
+ }
183
+
184
+ // --- POST /peek --------------------------------------------------------------
185
+ export interface PeekRequest {
186
+ file: string;
187
+ /** "line:col"; the line the panel highlights and scrolls to. Omitted or
188
+ * empty peeks from the top of the file. */
189
+ loc?: string;
190
+ }
191
+ export interface PeekResponse {
192
+ file: string;
193
+ /** 1-based line number of `lines[0]` — 1 unless the huge-file cap cut the
194
+ * window down. */
195
+ startLine: number;
196
+ /** 1-based line the loc points at, clamped into the file. */
197
+ focusLine: number;
198
+ /** Total lines in the file, so the client can say "N more lines" when the
199
+ * cap applied. */
200
+ totalLines: number;
201
+ /** The file's source lines — the whole file, unless it exceeds the server's
202
+ * huge-file cap, in which case a window around `focusLine`. Empty when
203
+ * `refused` is set. */
204
+ lines: string[];
205
+ /** Set when the file exists but isn't the user's to look at through the
206
+ * panel — e.g. a package-owned path like the `astro:assets` `<Image>`
207
+ * component. The panel shows this sentence instead of source, and no file
208
+ * contents are returned. Absent on success. */
209
+ refused?: string;
210
+ }
211
+
212
+ // --- POST /classify ----------------------------------------------------------
213
+ export interface ClassifyRequest extends SourceLoc {
214
+ /** Lowercased tag name of the clicked element. */
215
+ tag: string;
216
+ }
217
+ export interface ClassifyResult {
218
+ kind: ClassifyKind;
219
+ reason: string;
220
+ /** For `img` targets: whether src/alt are patchable. */
221
+ attrs?: { src: AttrState; alt: AttrState };
222
+ /**
223
+ * For `markup` targets: the element's inner *source*, outer whitespace
224
+ * trimmed. The popup edits this rather than the DOM's `innerHTML` — only the
225
+ * source knows how entities and quoting were spelled, and sending it back as
226
+ * the apply op's `original` keeps verify-then-patch comparing like with like.
227
+ */
228
+ markup?: { html: string };
229
+ /**
230
+ * For `expression` targets: which frontmatter string the text was traced to.
231
+ * `property` is the key holding it (`title`); `label` names the whole path
232
+ * for the editor's title (`benefits[].title`). No value is sent — for a
233
+ * `.map()` loop every card shares this classification, and which item is
234
+ * being edited is only settled by the text the client sends on apply.
235
+ */
236
+ expression?: { property: string; label: string };
237
+ }
238
+
239
+ // --- POST /apply -------------------------------------------------------------
240
+ /** One field edit within an apply request. */
241
+ export interface ApplyOp {
242
+ targetType: TargetType;
243
+ /** Rendered text / attr value the client saw — verified against the source
244
+ * before writing, so a stale page fails safe. */
245
+ original: string;
246
+ newText: string;
247
+ }
248
+
249
+ /** A verify-all-then-write-once batch of edits to ONE element (same file/loc/
250
+ * tag). The server applies each op to an in-memory copy of the source and only
251
+ * writes if every op verifies; a single failing op means nothing reaches disk,
252
+ * so a multi-field edit can never leave the file half-updated. Text edits send
253
+ * one op; the image panel sends up to two (src, alt). */
254
+ export interface ApplyRequestWire extends SourceLoc {
255
+ tag: string;
256
+ ops: ApplyOp[];
257
+ }
258
+
259
+ /** Error body shape shared by all endpoints (4xx/5xx). */
260
+ export interface ErrorResponse {
261
+ error: string;
262
+ code?: RefusalCode;
263
+ }
264
+
265
+ // --- Entry editor (spec: CMS panel for content-collection entries) -----------
266
+
267
+ /**
268
+ * How a collection's `schema:` is written. Only `function` —
269
+ * `({ image }) => z.object({…})` — has Astro's `image()` helper in scope, which
270
+ * is the whole of the difference the designer cares about.
271
+ */
272
+ export type SchemaForm = 'object' | 'function';
273
+
274
+ /** Widget/type a frontmatter field renders as in the entry panel. */
275
+ export type FieldType =
276
+ | 'text'
277
+ | 'textarea'
278
+ | 'date'
279
+ | 'number'
280
+ | 'boolean'
281
+ | 'select'
282
+ | 'tags'
283
+ | 'image'
284
+ | 'json'; // unrecognized shape → shown read-only
285
+
286
+ /** One frontmatter field, derived from the collection's zod schema (or, when
287
+ * no schema is resolvable, inferred from the entry's own values). */
288
+ export interface FieldDescriptor {
289
+ name: string;
290
+ /** Humanized name; overridable via entryEditor config. */
291
+ label: string;
292
+ type: FieldType;
293
+ /** No default and not optional/nullable. */
294
+ required: boolean;
295
+ /** Enum values, for `select`. */
296
+ options?: string[];
297
+ /**
298
+ * How an `image` field's value references its asset. Absent (the default)
299
+ * means a web-servable root-relative path, the shape a plain `<img src>`
300
+ * needs. `'relative'` marks a field backed by Astro's `image()` schema
301
+ * helper, whose values are paths relative to the *entry file* — the picker
302
+ * must resolve previews and write values in that shape instead.
303
+ */
304
+ assetRef?: 'relative';
305
+ /** Schema default — shown as placeholder when the key is absent. */
306
+ defaultValue?: unknown;
307
+ /** Whether the key exists in the file's frontmatter. */
308
+ present: boolean;
309
+ source: 'schema' | 'inferred';
310
+ /** Render the control disabled. Used by the Settings panel for an option
311
+ * `astro.config.mjs` owns, where accepting input would be a lie. */
312
+ readOnly?: boolean;
313
+ /** One-line prose shown under the control. */
314
+ help?: string;
315
+ }
316
+
317
+ /**
318
+ * The *editor* half of a field: which control it renders as, what it is called,
319
+ * whether it shows at all. Stored in `.astro-dev-edit.json` (or set in
320
+ * `astro.config.mjs` under `entryEditor.collections.<name>.fields`) and never in
321
+ * the collection's zod schema — the collection designer keeps the two halves
322
+ * visibly apart, because one is committed source and the other is local.
323
+ */
324
+ export interface FieldOverride {
325
+ widget?: FieldType;
326
+ label?: string;
327
+ hidden?: boolean;
328
+ }
329
+
330
+ /**
331
+ * A schema field as the designer *writes* it — the inverse of a
332
+ * {@link FieldDescriptor}, which is one read out of a live schema.
333
+ * `patcher/content-config.ts` renders these to zod expressions.
334
+ */
335
+ export interface SchemaFieldSpec {
336
+ name: string;
337
+ type: FieldType;
338
+ /** False renders `.optional()`; a `defaultValue` implies optional. */
339
+ required: boolean;
340
+ /** Renders `.default(…)`. Not supported for `date` or `image`. */
341
+ defaultValue?: unknown;
342
+ /** Enum values, for `type: 'select'`. */
343
+ options?: string[];
344
+ }
345
+
346
+ // --- POST /entry -------------------------------------------------------------
347
+ export interface EntryRequest {
348
+ /** Repo-relative path — from the page-source meta tag, from `/entry/resolve`,
349
+ * or from an Items row. */
350
+ file: string;
351
+ }
352
+ export interface EntryResponse {
353
+ file: string;
354
+ /** sha256 of the full file content; sent back on every write. */
355
+ etag: string;
356
+ /** Matched collection name, when the file maps to one. */
357
+ collection: string | null;
358
+ /** Repo-relative directory holding the collection's entries, when matched.
359
+ * The create drawer resolves `assetRef: 'relative'` values against it,
360
+ * since a new entry has no path of its own yet. */
361
+ collectionDir: string | null;
362
+ fields: FieldDescriptor[];
363
+ /** Parsed frontmatter values (JSON-safe). */
364
+ values: Record<string, unknown>;
365
+ /** Markdown body, \n-normalized. */
366
+ body: string;
367
+ bodyEditable: boolean;
368
+ }
369
+
370
+ // --- POST /entry/resolve -----------------------------------------------------
371
+ /**
372
+ * "Which content entry backs the page I am looking at?" — the meta tag's job,
373
+ * done by the server so a project need not emit one.
374
+ *
375
+ * Three layers narrow the answer, and each may refuse:
376
+ *
377
+ * 1. Astro's route manifest maps the pathname to a page file and a pattern. A
378
+ * pattern with no dynamic segment renders a *set* of entries, so it is not
379
+ * a detail page and gets no entry.
380
+ * 2. That page file is **scanned** for `getCollection('x')` / `getEntry('x'`,
381
+ * which binds the route to a collection rather than inferring one from the
382
+ * other. A route that fetches through a helper names nothing, and every
383
+ * enabled collection stays a candidate.
384
+ * 3. The pathname's tail is matched against the candidates' entry ids.
385
+ *
386
+ * A refusal is an answer: the entry button stays hidden exactly as it does on a
387
+ * page with no meta tag today.
388
+ */
389
+ export interface EntryResolveRequest {
390
+ /** `location.pathname` as the browser has it, base prefix included — the
391
+ * server strips the configured base itself, as `/page-source` does. */
392
+ pathname: string;
393
+ }
394
+
395
+ /** Why no entry was resolved.
396
+ * `disabled` — the entry editor is off; `no-routes` — the manifest is empty;
397
+ * `no-match` — no page route matches; `not-detail` — the route is static, so it
398
+ * renders a set rather than one entry; `not-enabled` — an entry *was* found and
399
+ * its collection has page editing switched off; `no-entry` — nothing on disk
400
+ * matches the pathname's tail (a dead URL a dynamic pattern still matched);
401
+ * `ambiguous` — two collections hold that id and nothing chose between them. */
402
+ export type EntryResolveRefusal =
403
+ | 'disabled'
404
+ | 'no-routes'
405
+ | 'no-match'
406
+ | 'not-detail'
407
+ | 'not-enabled'
408
+ | 'no-entry'
409
+ | 'ambiguous';
410
+
411
+ export interface EntryResolveResponse {
412
+ /** Repo-relative entry file — exactly what `POST /entry` takes. Null unless
413
+ * the entry resolved **and** its collection has page editing on. */
414
+ file: string | null;
415
+ /**
416
+ * The collection the page renders. Also set on `not-enabled`, together with
417
+ * {@link entryFile}, so the refusal notice can name what it is offering to
418
+ * switch on rather than saying "a collection".
419
+ */
420
+ collection: string | null;
421
+ /** The entry that was found while refusing `not-enabled`. Never editable —
422
+ * it exists so the offer can name the file it would open. */
423
+ entryFile: string | null;
424
+ /** With `not-enabled`: whether `astro.config.mjs` owns the switch. True means
425
+ * the offer is not made, because the write behind it would be refused. */
426
+ pageEditingLocked: boolean;
427
+ /** The route pattern that matched, e.g. "/articles/[...slug]". */
428
+ pattern: string | null;
429
+ refusal: EntryResolveRefusal | null;
430
+ }
431
+
432
+ // --- POST /entry/apply -------------------------------------------------------
433
+ export interface EntryApplyRequest {
434
+ file: string;
435
+ etag: string;
436
+ changes: {
437
+ /** ONLY changed keys. `null` clears an optional key from the file. */
438
+ frontmatter?: Record<string, unknown>;
439
+ body?: string;
440
+ };
441
+ }
442
+
443
+ // --- POST /entry/create ------------------------------------------------------
444
+ export interface EntryCreateRequest {
445
+ collection: string;
446
+ /** Filename without extension; sanitized server-side. */
447
+ slug: string;
448
+ frontmatter: Record<string, unknown>;
449
+ body: string;
450
+ }
451
+ export interface EntryCreateResponse {
452
+ /** Repo-relative path of the new file. */
453
+ file: string;
454
+ }
455
+
456
+ // --- POST /entry/delete ------------------------------------------------------
457
+ export interface EntryDeleteRequest {
458
+ file: string;
459
+ etag: string;
460
+ }
461
+
462
+ /** Entry endpoints extend the shared error shape with two more codes and
463
+ * optional per-field validation messages. */
464
+ export interface EntryErrorResponse {
465
+ error: string;
466
+ code?: RefusalCode | 'conflict' | 'validation' | 'exists';
467
+ /** Field name → message, for 422 validation failures. */
468
+ fieldErrors?: Record<string, string>;
469
+ }
470
+
471
+ // --- The media modal ---------------------------------------------------------
472
+ /** What the shared media modal resolves with. Lives here rather than in the
473
+ * client because `origin` is a server-side distinction: the modal's three
474
+ * sources produce the same kind of path by different routes, and a caller may
475
+ * want to know which (an Unsplash pick has already been downloaded and
476
+ * attributed by the time it reaches this shape). */
477
+ export interface MediaPick {
478
+ /** The value to write — already relative-converted if the field needs it. */
479
+ webPath: string;
480
+ origin: 'existing' | 'upload' | 'unsplash';
481
+ }
482
+
483
+ // --- POST /unsplash/search ---------------------------------------------------
484
+ /**
485
+ * A photo, reshaped by the server. Unsplash's own object carries exif, tags,
486
+ * topics, sponsorship, the full user record and a dozen URL variants; none of
487
+ * that crosses the wire, so it can never become an accidental API surface.
488
+ *
489
+ * Deliberately carries **no** raw or download_location URL. The server keeps
490
+ * those in a bounded in-memory map keyed by `id`, so `/unsplash/import` never
491
+ * fetches a URL the browser supplied — that removes an SSRF-shaped capability
492
+ * from the dev server. The cost is that an `id` minted before a restart imports
493
+ * as `409 expired`.
494
+ */
495
+ export interface UnsplashPhoto {
496
+ /** Unsplash's id — the only handle `/unsplash/import` accepts. */
497
+ id: string;
498
+ /** Grid thumbnail (urls.small), hotlinked in the picker UI only. */
499
+ thumbUrl: string;
500
+ /** Average colour, painted behind the tile while the thumb loads. */
501
+ color: string;
502
+ width: number;
503
+ height: number;
504
+ description: string;
505
+ photographer: string;
506
+ /** Profile URL with the required utm params already attached, so the client
507
+ * cannot forget them and the app name lives in one place. */
508
+ photographerUrl: string;
509
+ pageUrl: string;
510
+ }
511
+
512
+ export type UnsplashOrientation = 'any' | 'landscape' | 'portrait' | 'squarish';
513
+
514
+ export interface UnsplashSearchRequest {
515
+ query: string;
516
+ /** 1-based; clamped to >= 1 server-side. */
517
+ page?: number;
518
+ /** Clamped to Unsplash's own maximum of 30. */
519
+ perPage?: number;
520
+ orientation?: UnsplashOrientation;
521
+ }
522
+ export interface UnsplashSearchResponse {
523
+ photos: UnsplashPhoto[];
524
+ /** Total matches across all pages, for the "Load more (20 of 1,283)" label. */
525
+ total: number;
526
+ totalPages: number;
527
+ page: number;
528
+ /** Requests left this hour, when Unsplash reported it. Surfaced at the foot
529
+ * of the details rail — the demo tier allows only 50/hour. */
530
+ remaining?: number;
531
+ }
532
+
533
+ // --- POST /unsplash/import ---------------------------------------------------
534
+ /**
535
+ * How wide an imported photo is fetched. A closed set, not a number: the value
536
+ * reaches the URL the dev server fetches from Unsplash's CDN, so the server
537
+ * refuses anything off the list rather than clamping it. `'original'` sends no
538
+ * width at all — the raw file, bounded only by the 25 MB import cap.
539
+ *
540
+ * The runtime list lives in `shared/unsplash.ts`, since `protocol.ts` stays
541
+ * types-only — the same split `FIELD_TYPES` has.
542
+ */
543
+ export type UnsplashImportWidth = 800 | 1600 | 2400 | 'original';
544
+
545
+ export interface UnsplashImportRequest {
546
+ /** An id from a search in this dev-server process; see UnsplashPhoto. */
547
+ id: string;
548
+ /** Fetch this width instead of the resolved `unsplash.importWidth` — the
549
+ * right size is per-slot (a card thumbnail, an avatar), not per-project.
550
+ * Validated against the safelist server-side; an unknown value is refused,
551
+ * so the import fails loudly rather than landing the wrong size. */
552
+ width?: UnsplashImportWidth;
553
+ /** Same semantics as UploadRequest: marks an `image()`-backed field, so the
554
+ * bytes land in an importable `src/` dir rather than the web-servable one. */
555
+ assetRef?: 'relative';
556
+ /** Root-relative directory, confined server-side exactly as /upload's is. */
557
+ targetDir?: string;
558
+ }
559
+ export interface UnsplashImportResponse {
560
+ /** Web-servable path of the downloaded file. */
561
+ webPath: string;
562
+ /** The name actually written, after sanitising and clash-suffixing. */
563
+ filename: string;
564
+ }
565
+
566
+ /**
567
+ * Why an Unsplash call failed. These routes answer with an explicit code rather
568
+ * than throwing, because a thrown error becomes a 400 — which would read as
569
+ * "your query was malformed" when the real cause is upstream. See the module
570
+ * header in server/unsplash-routes.ts.
571
+ */
572
+ export type UnsplashErrorCode =
573
+ | 'disabled' // the integration option is off
574
+ | 'unconfigured' // enabled, but no access key resolved
575
+ | 'unauthorized' // Unsplash rejected the key
576
+ | 'rate-limited' // hourly quota exhausted (50/hr on the demo tier)
577
+ | 'expired' // photo id is no longer in the server's cache
578
+ | 'timeout' // the upstream call took too long
579
+ | 'too-large' // the download exceeded the size cap
580
+ | 'upstream'; // anything else on Unsplash's side
581
+
582
+ export interface UnsplashErrorResponse {
583
+ error: string;
584
+ code: UnsplashErrorCode;
585
+ }
586
+
587
+ // --- GET/POST /settings ------------------------------------------------------
588
+ /**
589
+ * Where a resolved access key came from, highest precedence first:
590
+ *
591
+ * - `config` — `unsplash.accessKey` in `astro.config.mjs`.
592
+ * - `env-shell` — an exported `UNSPLASH_ACCESS_KEY`. Vite's own env loader lets
593
+ * `process.env` override every `.env` file, so this outranks all of them.
594
+ * - `env-file` — `UNSPLASH_ACCESS_KEY` in a `.env` file. **This is the Settings
595
+ * panel's own store**: it writes `.env.local`, which Vite already refuses to
596
+ * serve. Within this source the files rank `.env` < `.env.local` <
597
+ * `.env.development` < `.env.development.local`, so the panel can override
598
+ * the first but not the last two — {@link SettingsResponse} reports which.
599
+ * - `file` — a legacy `unsplash.accessKey` in `.astro-dev-edit.json`. Read for
600
+ * back-compat only, and stripped the next time a key is saved.
601
+ *
602
+ * Not the same union as {@link OptionDescriptor.source}, which also has a
603
+ * `'file'` member — there it means the live settings file, here it means the
604
+ * legacy key inside it. `settings-panel.ts` consumes both.
605
+ */
606
+ export type SettingsSource = 'config' | 'env-shell' | 'env-file' | 'file';
607
+
608
+ /** Which Settings tab an option is grouped under. */
609
+ export type OptionGroup = 'general' | 'editing' | 'media' | 'unsplash';
610
+
611
+ /**
612
+ * One integration option, described well enough for the panel to render a
613
+ * control for it without knowing the option exists.
614
+ *
615
+ * That genericity is the point: options are declared once in the server's
616
+ * `OPTION_SPECS` table, and adding one needs no client change. `type` reuses
617
+ * {@link FieldType} so the panel can build the control through the entry
618
+ * editor's own `buildControl` registry rather than a parallel one.
619
+ */
620
+ export interface OptionDescriptor {
621
+ /** Flat wire key — what a {@link SettingsUpdateRequest} patch is keyed by. */
622
+ key: string;
623
+ label: string;
624
+ /** One-line prose shown under the control. */
625
+ help: string;
626
+ type: FieldType;
627
+ group: OptionGroup;
628
+ /** Enum values, for `type: 'select'`. */
629
+ choices?: string[];
630
+ /** The effective value, after precedence. */
631
+ value: unknown;
632
+ /** Which layer supplied it. */
633
+ source: 'default' | 'file' | 'config';
634
+ /**
635
+ * `astro.config.mjs` sets it, so storing a value here would do nothing — the
636
+ * panel renders the control read-only and says why. Also true for options
637
+ * consumed before the dev server exists, which can only come from the config.
638
+ */
639
+ locked: boolean;
640
+ /** Changing it needs a dev-server restart. */
641
+ restartRequired?: boolean;
642
+ }
643
+
644
+ /**
645
+ * The access key itself is **never** in this shape. Only whether one resolved,
646
+ * where from, whether the panel may change it, and a masked fragment.
647
+ */
648
+ export interface SettingsResponse {
649
+ /** Every option, in the order the panel should render them. Absent from a
650
+ * server that predates the option editor, so the panel must tolerate it. */
651
+ options?: OptionDescriptor[];
652
+ /**
653
+ * Files holding settings or the access key that the project's `.gitignore`
654
+ * does not cover, project-relative, in the order the panel should name them.
655
+ * Absent when everything is covered.
656
+ *
657
+ * Top-level rather than inside `unsplash`, because it is a fact about files:
658
+ * `.astro-dev-edit.json` exists as soon as any tab saves an option and has
659
+ * nothing to do with the photo source, while `.env.local` is only listed once
660
+ * it exists. The panel repeats it because this integration cannot edit a
661
+ * consuming project's ignore rules.
662
+ */
663
+ gitignoreWarning?: string[];
664
+ unsplash: {
665
+ /** Whether the option is enabled at all, independent of a key. */
666
+ enabled: boolean;
667
+ configured: boolean;
668
+ source: SettingsSource | null;
669
+ /** The file the key resolved from, project-relative — `.env.local`,
670
+ * `.env.development`, `.astro-dev-edit.json`. Absent for `config` and
671
+ * `env-shell`, which have no file, and when nothing is configured. */
672
+ sourceFile?: string;
673
+ /** Masked tail, e.g. `••••••••Ab3d`. Absent when nothing is configured. */
674
+ hint?: string;
675
+ /**
676
+ * Whether a save would actually take effect. False when something that
677
+ * outranks `.env.local` supplies the key — the Astro config, an exported
678
+ * shell variable, or `.env.development[.local]`.
679
+ *
680
+ * Server-computed rather than derived from `source`, because the answer
681
+ * depends on which *file* within `env-file` won; the panel cannot know the
682
+ * precedence and should not encode a copy of it.
683
+ */
684
+ writable: boolean;
685
+ /** Whether **Clear** can actually clear it. False for a key in `.env`,
686
+ * which a save can override but a removal from `.env.local` cannot unset. */
687
+ clearable: boolean;
688
+ /** A legacy `unsplash.accessKey` is still in `.astro-dev-edit.json` while
689
+ * something else wins. The next key save strips it; until then the panel
690
+ * nudges, since that file sits in the project's own tree. */
691
+ staleStoredKey?: true;
692
+ };
693
+ }
694
+
695
+ export interface SettingsUpdateRequest {
696
+ unsplash?: {
697
+ /** The access key to store, written to `.env.local`. An empty string clears
698
+ * it. Either may be **refused** — see `writable` / `clearable` on
699
+ * {@link SettingsResponse}. */
700
+ accessKey: string;
701
+ };
702
+ /**
703
+ * Sparse option patch — **only** the keys the panel changed, keyed by
704
+ * {@link OptionDescriptor.key}, mirroring `EntryApplyRequest.changes`. Values
705
+ * are `unknown` because the option set is server-declared; the server checks
706
+ * each one against its spec's type and refuses unknown, config-only and
707
+ * locked keys by name.
708
+ */
709
+ options?: Record<string, unknown>;
710
+ }
711
+
712
+ /** 422 from `POST /settings`: which keys were refused and why. Nothing is
713
+ * written when this comes back — the patch is all-or-nothing, so a rejected
714
+ * key cannot leave the file half-updated. */
715
+ export interface SettingsErrorResponse {
716
+ error: string;
717
+ code?: 'validation' | 'conflict' | 'disabled';
718
+ /** Option key → message. */
719
+ fieldErrors?: Record<string, string>;
720
+ }
721
+
722
+ // --- The collection designer -------------------------------------------------
723
+
724
+ /**
725
+ * A field row in the designer spans **two stores**, and the wire shape keeps
726
+ * them apart on purpose:
727
+ *
728
+ * - the **schema** half (`type`, `required`, `defaultValue`, add, remove) is
729
+ * written to the project's own `src/content.config.ts` — committed source that
730
+ * changes what `astro build` accepts;
731
+ * - the **editor** half ({@link FieldOverride}) is written to
732
+ * `.astro-dev-edit.json` — local, gitignored, and only affects the entry
733
+ * drawer.
734
+ *
735
+ * A request may carry both; the response says which half landed.
736
+ */
737
+
738
+ // --- POST /collections -------------------------------------------------------
739
+ export interface CollectionSummary {
740
+ name: string;
741
+ /** Repo-relative directory holding the collection's entries. */
742
+ dir: string;
743
+ /** Whether that directory exists yet. A collection declared in the config
744
+ * without its directory is a real and common state. */
745
+ dirExists: boolean;
746
+ /** Entry files found in it (.md/.mdx, recursive). */
747
+ entryCount: number;
748
+ /** The collection's fields — schema-derived when the schema resolved. */
749
+ fields: FieldDescriptor[];
750
+ /**
751
+ * Where {@link fields} came from. `'source'` means the config module didn't
752
+ * load (usually because it has an error), so the names come from the config
753
+ * text and the types are unknown — the panel says so rather than showing an
754
+ * authoritative-looking field table built from a guess.
755
+ */
756
+ fieldSource: 'schema' | 'source';
757
+ /** The zod expression for each field, keyed by name, as it stands in the
758
+ * config. Shown for anything the designer can't model. */
759
+ expressions: Record<string, string>;
760
+ /** How the `schema:` is written. Null when the designer couldn't read it — the
761
+ * panel then offers "open source" instead of controls. */
762
+ schemaForm: SchemaForm | null;
763
+ /** Why the schema isn't patchable, when it isn't. */
764
+ unrecognized?: string;
765
+ /** Whether the const is registered in `export const collections`. */
766
+ registered: boolean;
767
+ /** 1-based line of the collection's block in the config, for "open source". */
768
+ configLine?: number;
769
+ /** Editor-only overrides in force for this collection. */
770
+ overrides: Record<string, FieldOverride>;
771
+ /**
772
+ * Fields whose override `astro.config.mjs` owns. Storing one from the panel
773
+ * would resolve to nothing, so the editor half renders read-only — the same
774
+ * `locked` honesty {@link OptionDescriptor} has, applied per field.
775
+ */
776
+ lockedFields: string[];
777
+ /**
778
+ * Whether this collection's detail pages offer the entry drawer. Off by
779
+ * default: switching it on is what replaces hand-emitting the page-source
780
+ * meta tag. Editor-half state — it lives in `.astro-dev-edit.json` beside the
781
+ * field overrides, never in the project's committed content config.
782
+ */
783
+ pageEditing: boolean;
784
+ /** True when `astro.config.mjs` owns {@link pageEditing}, so the row's switch
785
+ * renders read-only for the same reason a config-set widget does. */
786
+ pageEditingLocked: boolean;
787
+ /**
788
+ * The route pattern whose page file names this collection, e.g.
789
+ * "/articles/[...slug]". Null when no dynamic page route was found to render
790
+ * it — a data collection, or a route that fetches through a helper. The panel
791
+ * says so rather than implying the switch will do nothing.
792
+ */
793
+ detailRoute: string | null;
794
+ }
795
+
796
+ export interface CollectionsResponse {
797
+ /** Repo-relative content config the designer reads and patches, or null when
798
+ * the project has none. Discovered server-side; a request never names it. */
799
+ configPath: string | null;
800
+ /** sha256 of that file, required by every write below. Null with no config. */
801
+ etag: string | null;
802
+ /** False when `schemaEditor` is off: the panel stays read-only for the schema
803
+ * half and still saves the editor half. */
804
+ schemaEditor: boolean;
805
+ collections: CollectionSummary[];
806
+ }
807
+
808
+ // --- POST /collection/schema/apply -------------------------------------------
809
+ export interface CollectionSchemaApplyRequest {
810
+ collection: string;
811
+ /** {@link CollectionsResponse.etag} as the panel read it. Required whenever
812
+ * `schema` is present; a stale one is refused rather than merged. */
813
+ etag?: string;
814
+ /** Schema edits. Applied form → removes → updates → adds, all against one
815
+ * in-memory copy, and written once — so a refusal anywhere leaves the file
816
+ * untouched. The form goes first because turning image support on and adding
817
+ * an image field are one save, and the field cannot render until it has. */
818
+ schema?: {
819
+ /** Switch the `schema:` between `z.object({…})` and `({ image }) =>
820
+ * z.object({…})`. Omitted means leave it as it is. */
821
+ form?: SchemaForm;
822
+ add?: SchemaFieldSpec[];
823
+ update?: SchemaFieldSpec[];
824
+ remove?: string[];
825
+ };
826
+ /** Editor-only overrides, merged per field. `null` clears one. */
827
+ overrides?: Record<string, FieldOverride | null>;
828
+ }
829
+
830
+ export interface CollectionApplyResponse {
831
+ /** True when everything asked for landed. */
832
+ ok: boolean;
833
+ /** Whether the config file was rewritten. */
834
+ schemaWritten: boolean;
835
+ /** Whether the settings file was rewritten. */
836
+ overridesWritten: boolean;
837
+ /** Fresh config etag after a schema write. */
838
+ etag?: string;
839
+ /** Why a half didn't land. */
840
+ error?: string;
841
+ code?: CollectionRefusal;
842
+ }
843
+
844
+ /** Why a designer write was refused.
845
+ * `conflict` — the config changed on disk since the panel read it.
846
+ * `disabled` — `schemaEditor: false`.
847
+ * `unrecognized` — a schema shape the patcher won't guess at.
848
+ * The rest name the collection or field. */
849
+ export type CollectionRefusal =
850
+ | 'conflict'
851
+ | 'disabled'
852
+ | 'unrecognized'
853
+ | 'missing'
854
+ | 'exists'
855
+ | 'unsupported';
856
+
857
+ // --- POST /collection/entries ------------------------------------------------
858
+ export interface CollectionEntriesRequest {
859
+ collection: string;
860
+ }
861
+
862
+ /** One entry as the Items view lists it. Enough to identify and open it, and
863
+ * nothing more — the body is never read into this response. */
864
+ export interface CollectionEntryItem {
865
+ /** Repo-relative path, which is exactly what `POST /entry` takes. */
866
+ file: string;
867
+ /** Filename without its extension. */
868
+ slug: string;
869
+ /** A title-ish frontmatter value, when the entry has one. */
870
+ title: string | null;
871
+ /** Last-modified time, ms since the epoch. */
872
+ mtime: number;
873
+ /** `draft: true` in the frontmatter. */
874
+ draft: boolean;
875
+ }
876
+
877
+ export interface CollectionEntriesResponse {
878
+ collection: string;
879
+ dir: string;
880
+ entries: CollectionEntryItem[];
881
+ /** True when the directory held more entries than the endpoint will read. The
882
+ * panel says so rather than presenting a partial list as complete. */
883
+ truncated?: boolean;
884
+ }
885
+
886
+ // --- POST /collection/page-editing -------------------------------------------
887
+ /**
888
+ * Switch a collection's in-page entry drawer on or off.
889
+ *
890
+ * Its own route rather than a corner of `/collection/schema/apply`, whose
891
+ * `overrides` are keyed **per field**: this is one flag about the collection.
892
+ * It writes the editor half (`.astro-dev-edit.json`), so it is gated on
893
+ * `entryEditor` and deliberately **not** on `schemaEditor` — no committed
894
+ * source is touched.
895
+ */
896
+ export interface CollectionPageEditingRequest {
897
+ collection: string;
898
+ enabled: boolean;
899
+ }
900
+
901
+ export interface CollectionPageEditingResponse {
902
+ ok: boolean;
903
+ /** The value now in force, re-resolved after the write. */
904
+ pageEditing: boolean;
905
+ }
906
+
907
+ // --- POST /collection/open ---------------------------------------------------
908
+ /** Launch the editor on the content config. Carries **no path**: the server
909
+ * opens the config it discovered, optionally at a collection's own line. */
910
+ export interface CollectionOpenRequest {
911
+ collection?: string;
912
+ }
913
+
914
+ // --- POST /collection/create -------------------------------------------------
915
+ export interface CollectionCreateRequest {
916
+ /** Identifier-safe; it becomes a `const` name and a registry key. */
917
+ name: string;
918
+ /** Repo-relative entry directory. Defaults to `src/content/<name>`. */
919
+ dir?: string;
920
+ /** Glob pattern for the loader. Defaults to `**\/*.md`. */
921
+ pattern?: string;
922
+ /** Which form to write the schema in. `function` puts Astro's `image()`
923
+ * helper in scope. Defaults to `object`; a spec holding an image field is
924
+ * promoted whatever this says, since the other combination cannot compile. */
925
+ schemaForm?: SchemaForm;
926
+ fields: SchemaFieldSpec[];
927
+ etag?: string;
928
+ }
929
+
930
+ export interface CollectionCreateResponse {
931
+ name: string;
932
+ /** The directory that was created. */
933
+ dir: string;
934
+ etag: string;
935
+ }