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,778 @@
1
+ import type { FieldType, UnsplashImportWidth } from '../shared/protocol.ts';
2
+ import {
3
+ UNSPLASH_DEFAULT_IMPORT_WIDTH,
4
+ UNSPLASH_IMPORT_WIDTH_CHOICES,
5
+ coerceImportWidth,
6
+ } from '../shared/unsplash.ts';
7
+ import type { EntryEditorOptions } from './content-config.ts';
8
+ import { readStoredOptions } from './settings.ts';
9
+
10
+ /**
11
+ * The integration's option vocabulary, and the resolver that turns it into the
12
+ * effective values a request runs against.
13
+ *
14
+ * **Why options resolve per request.** Everything here used to be collapsed
15
+ * once in `astro:config:setup` and captured by the middleware, so changing an
16
+ * option meant editing `astro.config.mjs` and restarting the dev server. The
17
+ * Settings panel needs to change them live, so resolution moved behind a thunk
18
+ * — the same shape `unsplash.resolve` already had, and for the same reason:
19
+ * nothing depends on hook ordering, and a saved change takes effect on the next
20
+ * request.
21
+ *
22
+ * **Precedence, highest first: `astro.config.mjs` → the settings file →
23
+ * {@link DEFAULTS}.** The config wins because it is code the user wrote
24
+ * deliberately, is committed, and is read by `astro build`. An option set there
25
+ * is reported `locked` and the panel renders it read-only rather than storing a
26
+ * value that resolution would ignore — the refusal `settings-panel.ts` already
27
+ * makes for a config-supplied Unsplash key, generalized.
28
+ *
29
+ * **{@link OPTION_SPECS} is the registry.** One entry per option carries its
30
+ * default, its wire label/help, the control it renders as, and how to read it
31
+ * out of a partial config. That single table drives resolution, the `/settings`
32
+ * response, and the panel's controls — so adding an option is one entry here,
33
+ * with no client change at all.
34
+ */
35
+
36
+ /** Options a consuming project passes to `devEdit()`. */
37
+ export interface DevEditOptions {
38
+ /** Kill switch. When false the integration does nothing at all. */
39
+ enabled?: boolean;
40
+ /** Directories scanned for replacement images offered in the swap panel. */
41
+ assetDirs?: string[];
42
+ /**
43
+ * Directory new image uploads are written to, relative to the project root.
44
+ * Must be a web-servable location — files here become a plain `<img src>` in
45
+ * the source, so anything outside `public/` works in dev but 404s in a
46
+ * production build. Defaults to `public`.
47
+ */
48
+ uploadDir?: string;
49
+ /**
50
+ * Fallback directory for uploads that back an `image()` schema field, relative
51
+ * to the project root. Those assets are *imported* by Astro rather than served
52
+ * verbatim, so they must live under `src/` — `public/` files can't be
53
+ * imported. Only used when the field has no existing value to sit beside;
54
+ * otherwise the upload lands in that value's own directory. Defaults to
55
+ * `src/assets`.
56
+ */
57
+ imageUploadDir?: string;
58
+ /** Extensions the patcher is allowed to write. */
59
+ editableExtensions?: string[];
60
+ /** Directories that writes are confined to. */
61
+ contentRoots?: string[];
62
+ /** Expose the click-to-source fallback. */
63
+ openInEditor?: boolean;
64
+ /** Reveal text-write destinations in the external editor. Default false. */
65
+ revealWrites?: boolean;
66
+ /** Best-effort pause before existing-file writes, 0–10000 ms. Default 1000. */
67
+ revealWriteDelayMs?: number;
68
+ /**
69
+ * The hover-pill CSS inspector: on hover, list an element's classes and ID,
70
+ * and reveal the CSS rules each one applies (read from the browser, no server
71
+ * round-trip) with a link to open the defining file at the rule. The
72
+ * open-at-rule jump additionally requires `openInEditor`. `false` disables the
73
+ * whole surface (no chips render).
74
+ */
75
+ cssInspector?: boolean;
76
+ /**
77
+ * Who emits the `data-astro-source-*` attributes the feature rides on.
78
+ * `'auto'` (default): Astro's own compiler on Astro 5/6; injected by this
79
+ * integration on Astro ≥7, whose Rust compiler doesn't emit them
80
+ * (withastro/compiler-rs#96). `'force'` always injects (also lifts the
81
+ * dev-toolbar requirement on 5/6); `'off'` never injects.
82
+ */
83
+ sourceAnnotations?: 'auto' | 'force' | 'off';
84
+ /**
85
+ * The CMS-style entry panel for content-collection pages that emit the
86
+ * `astro-dev-edit:page-source` meta tag. Zero-config for conventional
87
+ * `src/content/<name>/` layouts; `false` disables the whole surface.
88
+ */
89
+ entryEditor?: false | EntryEditorOptions;
90
+ /**
91
+ * The collection designer's **schema writes**. When true (the default) the
92
+ * Collections tab can add, retype and remove fields and append collections,
93
+ * which patches the project's own `src/content.config.ts`. `false` keeps the
94
+ * tab read-only: collections and their fields are still listed, and the
95
+ * editor-only overrides (widget, label, hidden) still save, since those go to
96
+ * `.astro-dev-edit.json` rather than to committed source.
97
+ */
98
+ schemaEditor?: boolean;
99
+ /**
100
+ * The Unsplash photo source in the media picker. `unsplash: {}` turns it on
101
+ * with defaults; omitted (the default) leaves it off entirely, and the media
102
+ * modal renders as a single-source project-asset grid.
103
+ *
104
+ * Every user brings their own access key. The recommended way to give one is
105
+ * the overlay's own Settings panel (admin bar → Settings), which writes it to
106
+ * a gitignored `.env.local` — see `accessKey` for why not here.
107
+ */
108
+ unsplash?: false | UnsplashOptions;
109
+ }
110
+
111
+ /** Options for the Unsplash photo source. See `DevEditOptions.unsplash`. */
112
+ export interface UnsplashOptions {
113
+ /**
114
+ * Access key, as an escape hatch for programmatic config. **Not the
115
+ * recommended path:** `astro.config.mjs` is committed *and* is read by
116
+ * `astro build`, so a key here travels with the repo. Prefer the Settings
117
+ * panel, which writes `UNSPLASH_ACCESS_KEY` into `.env.local` — the same
118
+ * place you would put it by hand. When set here it wins over every other
119
+ * source, and the Settings panel says so rather than accepting a value that
120
+ * would do nothing.
121
+ */
122
+ accessKey?: string;
123
+ /**
124
+ * Application name sent as `utm_source` on every photographer credit link,
125
+ * as the Unsplash API guidelines require. Should match the application name
126
+ * registered at unsplash.com/oauth/applications. Defaults to
127
+ * `astro-dev-edit`.
128
+ */
129
+ appName?: string;
130
+ /** Results per search page. Clamped to Unsplash's own maximum of 30.
131
+ * Defaults to 20. */
132
+ perPage?: number;
133
+ /**
134
+ * How wide an imported photo is fetched from Unsplash's CDN. `'original'`
135
+ * asks for the raw file at full resolution; every other value only ever
136
+ * shrinks, since the request carries `fit=max`. Defaults to `2400`.
137
+ *
138
+ * Project-wide, and overridable per import from the picker's size select —
139
+ * the right size is a property of the slot the image goes in, not of the
140
+ * project. See `shared/unsplash.ts` for the safelist.
141
+ */
142
+ importWidth?: UnsplashImportWidth;
143
+ }
144
+
145
+ /** Every option's effective value for one request — no optionals left. */
146
+ export type ResolvedOptions = Required<DevEditOptions>;
147
+
148
+ /**
149
+ * What the Settings panel stores. `Partial<DevEditOptions>` for every option
150
+ * with a single value, plus explicit on/off flags for the two *features*.
151
+ *
152
+ * The flags exist because `DevEditOptions` encodes a feature as
153
+ * `false | { …detail }`, which cannot hold "switched off" and "configured like
154
+ * this" at the same time. In a config file that is fine — the user retypes the
155
+ * object. In a store the panel writes, switching a feature off would silently
156
+ * discard the per-collection widget overrides or the Unsplash app name, and
157
+ * switching it back on would come up empty. So the stored document keeps the
158
+ * detail under `entryEditor` / `unsplash` unconditionally and the on/off bit
159
+ * beside it.
160
+ */
161
+ export interface StoredOptions extends Partial<DevEditOptions> {
162
+ entryEditorEnabled?: boolean;
163
+ unsplashEnabled?: boolean;
164
+ }
165
+
166
+ export const DEFAULTS: ResolvedOptions = {
167
+ enabled: true,
168
+ assetDirs: ['src/assets', 'public'],
169
+ uploadDir: 'public',
170
+ imageUploadDir: 'src/assets',
171
+ editableExtensions: ['.astro', '.md', '.mdx'],
172
+ contentRoots: ['src', 'public'],
173
+ openInEditor: true,
174
+ revealWrites: false,
175
+ revealWriteDelayMs: 1000,
176
+ cssInspector: true,
177
+ sourceAnnotations: 'auto',
178
+ entryEditor: {},
179
+ schemaEditor: true,
180
+ // Off unless asked for: the feature reaches a third-party API and needs a key
181
+ // the user has to supply, so opting in is deliberate.
182
+ unsplash: false,
183
+ };
184
+
185
+ /** Unsplash's own ceiling on `per_page`. */
186
+ export const UNSPLASH_MAX_PER_PAGE = 30;
187
+
188
+ /** Which Settings tab an option is grouped under. */
189
+ export type OptionGroup = 'general' | 'editing' | 'media' | 'unsplash';
190
+
191
+ /**
192
+ * One option, as both a resolution rule and a control the panel renders.
193
+ *
194
+ * `read` returns `undefined` for "this source is silent about the option",
195
+ * which is what separates a deliberate `false` from an absent key — and
196
+ * therefore what {@link OptionDescriptor.locked} means.
197
+ */
198
+ interface OptionSpec {
199
+ /** Flat wire key. Also the settings-file key for everything but the
200
+ * `unsplash*` trio, which flattens a nested config object. */
201
+ key: string;
202
+ label: string;
203
+ help: string;
204
+ type: FieldType;
205
+ group: OptionGroup;
206
+ /** Enum values, for `type: 'select'`. */
207
+ choices?: string[];
208
+ /**
209
+ * Consumed in `astro:config:setup`, before any dev server exists — so it can
210
+ * only come from the config, and changing it needs a restart. The panel
211
+ * renders these read-only. `enabled` is additionally config-only because
212
+ * storing `false` here would lock the user out of the UI that set it.
213
+ */
214
+ configOnly?: boolean;
215
+ fallback: unknown;
216
+ /** Read from the config layer. `undefined` = this layer is silent. */
217
+ read(o: Partial<DevEditOptions>): unknown;
218
+ /**
219
+ * Read from the stored layer, when it encodes the option differently — only
220
+ * the two feature toggles do, and only because the store separates a toggle
221
+ * from its detail. Defaults to {@link read}.
222
+ */
223
+ readStored?(o: StoredOptions): unknown;
224
+ }
225
+
226
+ /** The registry. Adding an option is one entry — resolution, the `/settings`
227
+ * response and the panel's control all follow from it. */
228
+ const OPTION_SPECS: readonly OptionSpec[] = [
229
+ {
230
+ key: 'enabled',
231
+ label: 'Integration enabled',
232
+ help: 'The kill switch. Set in astro.config.mjs only — turning it off from here would remove the UI that turns it back on.',
233
+ type: 'boolean',
234
+ group: 'general',
235
+ configOnly: true,
236
+ fallback: DEFAULTS.enabled,
237
+ read: (o) => o.enabled,
238
+ },
239
+ {
240
+ key: 'sourceAnnotations',
241
+ label: 'Source annotations',
242
+ help: 'Who emits the data-astro-source-* attributes everything rides on. "auto" uses Astro\'s compiler on 5/6 and injects them on 7+. Registers a Vite plugin, so it is config-only.',
243
+ type: 'select',
244
+ choices: ['auto', 'force', 'off'],
245
+ group: 'general',
246
+ configOnly: true,
247
+ fallback: DEFAULTS.sourceAnnotations,
248
+ read: (o) => o.sourceAnnotations,
249
+ },
250
+ {
251
+ key: 'contentRoots',
252
+ label: 'Content roots',
253
+ help: 'Writes are confined to these directories, resolved through symlinks. Narrowing this is the main way to limit what the editor can touch.',
254
+ type: 'tags',
255
+ group: 'general',
256
+ fallback: DEFAULTS.contentRoots,
257
+ read: (o) => o.contentRoots,
258
+ },
259
+ {
260
+ key: 'editableExtensions',
261
+ label: 'Editable extensions',
262
+ help: 'File extensions the patcher may write. Only .astro supports in-place element editing today; .md and .mdx are collection entries.',
263
+ type: 'tags',
264
+ group: 'general',
265
+ fallback: DEFAULTS.editableExtensions,
266
+ read: (o) => o.editableExtensions,
267
+ },
268
+ {
269
+ key: 'openInEditor',
270
+ label: 'Open in editor',
271
+ help: 'Expose the "Open source" buttons and the jump-to-file links that launch your editor at a source location.',
272
+ type: 'boolean',
273
+ group: 'editing',
274
+ fallback: DEFAULTS.openInEditor,
275
+ read: (o) => o.openInEditor,
276
+ },
277
+ {
278
+ key: 'revealWrites',
279
+ label: 'Show changed files in editor',
280
+ help: 'Reveal text files before saving, with a best-effort delay. New files open after creation. Independent of manual Open controls; excludes uploads and deletions. Settings files may contain your Unsplash key.',
281
+ type: 'boolean',
282
+ group: 'editing',
283
+ fallback: DEFAULTS.revealWrites,
284
+ read: (o) => o.revealWrites,
285
+ },
286
+ {
287
+ key: 'revealWriteDelayMs',
288
+ label: 'Delay before writing (ms)',
289
+ help: 'Wait 0–10000 milliseconds after requesting the editor to open an existing file. This cannot confirm that the file is visible.',
290
+ type: 'number',
291
+ group: 'editing',
292
+ fallback: DEFAULTS.revealWriteDelayMs,
293
+ read: (o) => o.revealWriteDelayMs,
294
+ },
295
+ {
296
+ key: 'cssInspector',
297
+ label: 'CSS inspector',
298
+ help: 'The hover pill\'s class and ID chips, and the CSS rules each one applies. Off means no chips render at all.',
299
+ type: 'boolean',
300
+ group: 'editing',
301
+ fallback: DEFAULTS.cssInspector,
302
+ read: (o) => o.cssInspector,
303
+ },
304
+ {
305
+ key: 'entryEditor',
306
+ label: 'Entry editor',
307
+ help: 'The CMS drawer for content-collection entries. Off disables every /entry endpoint and the admin bar button.',
308
+ type: 'boolean',
309
+ group: 'editing',
310
+ fallback: DEFAULTS.entryEditor !== false,
311
+ // An object means the feature is on and configured; only an explicit
312
+ // `false` is a config-level kill. A configured object therefore locks the
313
+ // toggle on without locking the per-collection detail, which merges.
314
+ read: (o) => (o.entryEditor === undefined ? undefined : o.entryEditor !== false),
315
+ readStored: (o) => o.entryEditorEnabled,
316
+ },
317
+ {
318
+ key: 'schemaEditor',
319
+ label: 'Schema editing',
320
+ help: 'Let the Collections tab write your src/content.config.ts — add, retype and remove schema fields, and append collections. Off leaves the tab read-only.',
321
+ type: 'boolean',
322
+ group: 'editing',
323
+ fallback: DEFAULTS.schemaEditor,
324
+ read: (o) => o.schemaEditor,
325
+ },
326
+ {
327
+ key: 'assetDirs',
328
+ label: 'Asset directories',
329
+ help: 'Directories scanned for the images offered in the media picker\'s project grid.',
330
+ type: 'tags',
331
+ group: 'media',
332
+ fallback: DEFAULTS.assetDirs,
333
+ read: (o) => o.assetDirs,
334
+ },
335
+ {
336
+ key: 'uploadDir',
337
+ label: 'Upload directory',
338
+ help: 'Where new uploads are written. Must sit under public/ — files here become a plain <img src>, which 404s in a production build from anywhere else.',
339
+ type: 'text',
340
+ group: 'media',
341
+ fallback: DEFAULTS.uploadDir,
342
+ read: (o) => o.uploadDir,
343
+ },
344
+ {
345
+ key: 'imageUploadDir',
346
+ label: 'image() upload directory',
347
+ help: 'Fallback for uploads backing an image() schema field. Must sit under src/ — Astro imports those assets, and public/ files cannot be imported.',
348
+ type: 'text',
349
+ group: 'media',
350
+ fallback: DEFAULTS.imageUploadDir,
351
+ read: (o) => o.imageUploadDir,
352
+ },
353
+ {
354
+ key: 'unsplashEnabled',
355
+ label: 'Unsplash photo source',
356
+ help: 'Adds an Unsplash tab to the media picker. Needs an access key, below.',
357
+ type: 'boolean',
358
+ group: 'unsplash',
359
+ fallback: DEFAULTS.unsplash !== false,
360
+ read: (o) => (o.unsplash === undefined ? undefined : o.unsplash !== false),
361
+ readStored: (o) => o.unsplashEnabled,
362
+ },
363
+ {
364
+ key: 'unsplashAppName',
365
+ label: 'Application name',
366
+ help: 'Sent as utm_source on every photographer credit link, as the Unsplash API guidelines require. Should match the name you registered.',
367
+ type: 'text',
368
+ group: 'unsplash',
369
+ fallback: 'astro-dev-edit',
370
+ read: (o) => (o.unsplash ? o.unsplash.appName : undefined),
371
+ },
372
+ {
373
+ key: 'unsplashPerPage',
374
+ label: 'Results per page',
375
+ help: `How many photos each search returns. Clamped to Unsplash's own maximum of ${UNSPLASH_MAX_PER_PAGE}.`,
376
+ type: 'number',
377
+ group: 'unsplash',
378
+ fallback: 20,
379
+ read: (o) => (o.unsplash ? o.unsplash.perPage : undefined),
380
+ },
381
+ {
382
+ key: 'unsplashImportWidth',
383
+ label: 'Import width',
384
+ help: 'How wide an imported photo is downloaded. Where the picker\u2019s size select starts \u2014 change it there for one import. "original" asks for the full-resolution file; the others only ever shrink, never upscale.',
385
+ type: 'select',
386
+ group: 'unsplash',
387
+ // A select's values are strings, and the settings file is JSON a human may
388
+ // hand-edit, so a width travels as text and is parsed once, by
389
+ // `coerceImportWidth`. `read` stringifies so a numeric config value still
390
+ // matches a choice.
391
+ choices: [...UNSPLASH_IMPORT_WIDTH_CHOICES],
392
+ fallback: String(UNSPLASH_DEFAULT_IMPORT_WIDTH),
393
+ read: (o) =>
394
+ o.unsplash && o.unsplash.importWidth !== undefined
395
+ ? String(o.unsplash.importWidth)
396
+ : undefined,
397
+ },
398
+ ];
399
+
400
+ /** Keys the Settings panel may write — everything the config does not own
401
+ * outright. Exported so the route can refuse anything else by name. */
402
+ export const WRITABLE_OPTION_KEYS: readonly string[] = OPTION_SPECS.filter(
403
+ (s) => !s.configOnly,
404
+ ).map((s) => s.key);
405
+
406
+ /** Where an effective value came from. */
407
+ export type OptionSource = 'default' | 'file' | 'config';
408
+
409
+ /** One option as the panel sees it: the control to render, the effective value,
410
+ * and whether the config owns it. Mirrors `OptionDescriptor` in protocol.ts. */
411
+ export interface ResolvedOption {
412
+ key: string;
413
+ label: string;
414
+ help: string;
415
+ type: FieldType;
416
+ group: OptionGroup;
417
+ choices?: string[];
418
+ value: unknown;
419
+ source: OptionSource;
420
+ locked: boolean;
421
+ restartRequired: boolean;
422
+ }
423
+
424
+ export interface OptionsResolution {
425
+ /** The effective options this request runs against. */
426
+ options: ResolvedOptions;
427
+ /** The same values, described for the Settings panel. */
428
+ described: ResolvedOption[];
429
+ }
430
+
431
+ export interface OptionsResolverDeps {
432
+ /** Project root (fsPath) — where the settings file lives. */
433
+ root: string;
434
+ /** Exactly what the project passed to `devEdit()`, **not** merged with
435
+ * DEFAULTS: `key in configOptions` is what makes an option `locked`. */
436
+ configOptions: Partial<DevEditOptions>;
437
+ }
438
+
439
+ export interface OptionsResolver {
440
+ /** Resolve for one request. Cheap — one small JSON read, the same cost
441
+ * `resolveUnsplashKey` already pays per request. */
442
+ resolve(): Promise<OptionsResolution>;
443
+ /**
444
+ * The **config layer's** `entryEditor` alone, unmerged — what the project
445
+ * literally wrote in `astro.config.mjs`, or undefined when it wrote nothing.
446
+ *
447
+ * `resolve()` deliberately hands back only effective values, which cannot
448
+ * answer "does the config own this?" for a leaf inside `entryEditor`: a config
449
+ * and a stored value that happen to agree are indistinguishable there. The
450
+ * collection designer needs that answer per collection — a switch it renders
451
+ * as writable but resolution would ignore is worse than a locked one — so the
452
+ * layer is exposed rather than inferred. Synchronous: this is the value the
453
+ * dev server started with, and it cannot change without a restart.
454
+ */
455
+ entryEditorConfig(): EntryEditorOptions | undefined;
456
+ }
457
+
458
+ /** Deep-merge `entryEditor`, config leaf winning over stored leaf.
459
+ *
460
+ * `entryEditor` is the one option that is not a single value: a project may
461
+ * configure `collections.blog.fields.excerpt.widget` in code while the panel
462
+ * adds `collections.notes` at runtime, and both must apply. Merging per leaf
463
+ * keeps the config authoritative exactly where it speaks. */
464
+ function mergeEntryEditor(
465
+ stored: EntryEditorOptions | undefined,
466
+ config: EntryEditorOptions | undefined,
467
+ ): EntryEditorOptions {
468
+ if (!stored) return config ?? {};
469
+ if (!config) return stored;
470
+ const names = new Set([
471
+ ...Object.keys(stored.collections ?? {}),
472
+ ...Object.keys(config.collections ?? {}),
473
+ ]);
474
+ const collections: NonNullable<EntryEditorOptions['collections']> = {};
475
+ for (const name of names) {
476
+ const s = stored.collections?.[name] ?? {};
477
+ const c = config.collections?.[name] ?? {};
478
+ const fieldNames = new Set([...Object.keys(s.fields ?? {}), ...Object.keys(c.fields ?? {})]);
479
+ const fields: NonNullable<typeof s.fields> = {};
480
+ for (const f of fieldNames) fields[f] = { ...s.fields?.[f], ...c.fields?.[f] };
481
+ collections[name] = {
482
+ ...s,
483
+ ...c,
484
+ ...(fieldNames.size > 0 ? { fields } : {}),
485
+ };
486
+ }
487
+ return {
488
+ ...stored,
489
+ ...config,
490
+ ...(names.size > 0 ? { collections } : {}),
491
+ };
492
+ }
493
+
494
+ /** Rebuild the nested option shape from resolved flat values. Explicit rather
495
+ * than driven by per-spec writers, because the `unsplash*` trio collapses to a
496
+ * single `false` and order-of-assignment bugs there are invisible. */
497
+ function toResolvedOptions(
498
+ flat: Map<string, unknown>,
499
+ entryEditor: EntryEditorOptions,
500
+ accessKeyFromConfig: string | undefined,
501
+ ): ResolvedOptions {
502
+ const unsplashOn = flat.get('unsplashEnabled') === true;
503
+ return {
504
+ enabled: flat.get('enabled') as boolean,
505
+ assetDirs: flat.get('assetDirs') as string[],
506
+ uploadDir: flat.get('uploadDir') as string,
507
+ imageUploadDir: flat.get('imageUploadDir') as string,
508
+ editableExtensions: flat.get('editableExtensions') as string[],
509
+ contentRoots: flat.get('contentRoots') as string[],
510
+ openInEditor: flat.get('openInEditor') === true,
511
+ revealWrites: flat.get('revealWrites') === true,
512
+ revealWriteDelayMs: validRevealDelay(flat.get('revealWriteDelayMs')) ? flat.get('revealWriteDelayMs') as number : DEFAULTS.revealWriteDelayMs,
513
+ schemaEditor: flat.get('schemaEditor') === true,
514
+ cssInspector: flat.get('cssInspector') === true,
515
+ sourceAnnotations: flat.get('sourceAnnotations') as 'auto' | 'force' | 'off',
516
+ entryEditor: flat.get('entryEditor') === true ? entryEditor : false,
517
+ unsplash: unsplashOn
518
+ ? {
519
+ // The key never travels through the option table — it resolves
520
+ // separately, through settings.ts, and is only ever read there.
521
+ ...(accessKeyFromConfig ? { accessKey: accessKeyFromConfig } : {}),
522
+ appName: flat.get('unsplashAppName') as string,
523
+ perPage: flat.get('unsplashPerPage') as number,
524
+ // Off-safelist can only mean a hand-edited settings file; fall back
525
+ // rather than resolve to a width the import route would refuse.
526
+ importWidth:
527
+ coerceImportWidth(flat.get('unsplashImportWidth')) ?? UNSPLASH_DEFAULT_IMPORT_WIDTH,
528
+ }
529
+ : false,
530
+ };
531
+ }
532
+
533
+ export function createOptionsResolver(deps: OptionsResolverDeps): OptionsResolver {
534
+ const { root, configOptions } = deps;
535
+
536
+ return {
537
+ entryEditorConfig() {
538
+ // `false` is the kill switch, not a detail — it owns no per-collection leaf.
539
+ return configOptions.entryEditor === false ? undefined : configOptions.entryEditor;
540
+ },
541
+
542
+ async resolve() {
543
+ // Every failure inside degrades to "nothing stored", so a corrupt or
544
+ // unreadable settings file falls back to config + defaults rather than
545
+ // breaking every endpoint.
546
+ const stored = await readStoredOptions(root);
547
+
548
+ const flat = new Map<string, unknown>();
549
+ const described: ResolvedOption[] = [];
550
+
551
+ for (const spec of OPTION_SPECS) {
552
+ const fromConfig = spec.read(configOptions);
553
+ const fromFile = spec.configOnly
554
+ ? undefined
555
+ : (spec.readStored ?? spec.read)(stored);
556
+
557
+ let value: unknown;
558
+ let source: OptionSource;
559
+ if (fromConfig !== undefined) {
560
+ value = fromConfig;
561
+ source = 'config';
562
+ } else if (fromFile !== undefined) {
563
+ value = fromFile;
564
+ source = 'file';
565
+ } else {
566
+ value = spec.fallback;
567
+ source = 'default';
568
+ }
569
+
570
+ flat.set(spec.key, value);
571
+ described.push({
572
+ key: spec.key,
573
+ label: spec.label,
574
+ help: spec.help,
575
+ type: spec.type,
576
+ group: spec.group,
577
+ ...(spec.choices ? { choices: spec.choices } : {}),
578
+ value,
579
+ source,
580
+ // Config-only options are always locked; the rest lock only when the
581
+ // config actually speaks about them.
582
+ locked: Boolean(spec.configOnly) || source === 'config',
583
+ restartRequired: Boolean(spec.configOnly),
584
+ });
585
+ }
586
+
587
+ const entryEditor = mergeEntryEditor(
588
+ // The stored side holds detail only — its on/off bit is
589
+ // `entryEditorEnabled`, already folded into `flat` above.
590
+ stored.entryEditor === false ? undefined : stored.entryEditor,
591
+ configOptions.entryEditor === false ? undefined : configOptions.entryEditor,
592
+ );
593
+ const configUnsplash = configOptions.unsplash;
594
+ const accessKey = configUnsplash ? configUnsplash.accessKey : undefined;
595
+
596
+ return {
597
+ options: toResolvedOptions(flat, entryEditor, accessKey),
598
+ described,
599
+ };
600
+ },
601
+ };
602
+ }
603
+
604
+ // --- The write side ----------------------------------------------------------
605
+
606
+ /** Look a spec up by wire key. */
607
+ function specFor(key: string): OptionSpec | undefined {
608
+ return OPTION_SPECS.find((s) => s.key === key);
609
+ }
610
+
611
+ function validRevealDelay(value: unknown): value is number {
612
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 10000;
613
+ }
614
+
615
+ export interface CoercedPatch {
616
+ /** Wire key → validated value, for keys that passed. */
617
+ values: Map<string, unknown>;
618
+ /** Wire key → message, for keys that did not. */
619
+ errors: Record<string, string>;
620
+ }
621
+
622
+ /**
623
+ * Validate a flat patch from the Settings panel. The client is typed but not
624
+ * trusted — these values become the project's write confinement and upload
625
+ * targets, so every one is checked against its spec's declared type, and an
626
+ * unknown or config-only key is an error rather than a silent no-op.
627
+ *
628
+ * `locked` is deliberately **not** checked here: it depends on the resolution
629
+ * this patch is about to change, so the route checks it against a fresh resolve.
630
+ */
631
+ export function coerceOptionPatch(patch: Record<string, unknown>): CoercedPatch {
632
+ const values = new Map<string, unknown>();
633
+ const errors: Record<string, string> = {};
634
+
635
+ for (const key of Object.keys(patch)) {
636
+ if (!specFor(key)) errors[key] = 'unknown option';
637
+ }
638
+
639
+ // Table order, not the client's JSON key order: `applyOptionPatch` relies on
640
+ // `unsplashEnabled` being applied before the sub-options it gates.
641
+ for (const spec of OPTION_SPECS) {
642
+ const key = spec.key;
643
+ if (!(key in patch)) continue;
644
+ const raw = patch[key];
645
+ if (spec.configOnly) {
646
+ errors[key] = `${spec.label} can only be set in astro.config.mjs`;
647
+ continue;
648
+ }
649
+ // `collectChanges` maps an emptied optional field to `null` (= remove the
650
+ // key), which is right for frontmatter and meaningless here: an option
651
+ // always has an effective value, and there is no "absent" state to fall back
652
+ // to. Caught up front so the message is about being empty rather than about
653
+ // the type.
654
+ if (raw === null) {
655
+ errors[key] = 'cannot be empty';
656
+ continue;
657
+ }
658
+ switch (spec.type) {
659
+ case 'boolean': {
660
+ if (typeof raw !== 'boolean') {
661
+ errors[key] = 'expected true or false';
662
+ continue;
663
+ }
664
+ values.set(key, raw);
665
+ break;
666
+ }
667
+ case 'number': {
668
+ const n = typeof raw === 'number' ? raw : Number(raw);
669
+ if (key === 'revealWriteDelayMs' && !validRevealDelay(n)) {
670
+ errors[key] = 'expected an integer from 0 to 10000';
671
+ continue;
672
+ }
673
+ if (!Number.isFinite(n)) {
674
+ errors[key] = 'expected a number';
675
+ continue;
676
+ }
677
+ values.set(key, Math.trunc(n));
678
+ break;
679
+ }
680
+ case 'select': {
681
+ if (typeof raw !== 'string' || !spec.choices?.includes(raw)) {
682
+ errors[key] = `expected one of ${spec.choices?.join(', ')}`;
683
+ continue;
684
+ }
685
+ values.set(key, raw);
686
+ break;
687
+ }
688
+ case 'tags': {
689
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : null;
690
+ if (!list) {
691
+ errors[key] = 'expected a list';
692
+ continue;
693
+ }
694
+ const clean = list.map((v) => String(v).trim()).filter(Boolean);
695
+ if (clean.length === 0) {
696
+ errors[key] = 'at least one entry is required';
697
+ continue;
698
+ }
699
+ values.set(key, clean);
700
+ break;
701
+ }
702
+ default: {
703
+ // 'text' and anything a future spec adds: a trimmed non-empty string.
704
+ if (typeof raw !== 'string') {
705
+ errors[key] = 'expected text';
706
+ continue;
707
+ }
708
+ const trimmed = raw.trim();
709
+ if (!trimmed) {
710
+ errors[key] = 'cannot be empty';
711
+ continue;
712
+ }
713
+ values.set(key, trimmed);
714
+ break;
715
+ }
716
+ }
717
+ }
718
+
719
+ return { values, errors };
720
+ }
721
+
722
+ /**
723
+ * Merge a validated flat patch into the stored option document.
724
+ *
725
+ * Merge-not-replace, like `saveUnsplashKey` — the panel sends only what changed,
726
+ * so anything absent from the patch must survive. The `unsplash*` trio folds
727
+ * back into one nested object, and `accessKey` is **stripped**: the key lives at
728
+ * the document's top level, resolved by `settings.ts`, and must never be written
729
+ * anywhere the option table can echo back to the browser.
730
+ */
731
+ export function applyOptionPatch(
732
+ current: StoredOptions,
733
+ values: Map<string, unknown>,
734
+ ): StoredOptions {
735
+ const next: StoredOptions = { ...current };
736
+
737
+ for (const [key, value] of values) {
738
+ switch (key) {
739
+ // Both feature toggles set the flag beside the detail, never the detail
740
+ // itself — see {@link StoredOptions} for why they are separate.
741
+ case 'entryEditor': {
742
+ next.entryEditorEnabled = value === true;
743
+ break;
744
+ }
745
+ case 'unsplashEnabled': {
746
+ next.unsplashEnabled = value === true;
747
+ break;
748
+ }
749
+ case 'unsplashAppName':
750
+ case 'unsplashPerPage':
751
+ case 'unsplashImportWidth': {
752
+ // Stored whether or not the feature is on: in this document the object
753
+ // is detail, not the on/off bit, so writing it enables nothing.
754
+ const base = next.unsplash === false || next.unsplash === undefined ? {} : next.unsplash;
755
+ const merged: UnsplashOptions = { ...base };
756
+ if (key === 'unsplashAppName') merged.appName = value as string;
757
+ else if (key === 'unsplashPerPage') merged.perPage = value as number;
758
+ // Stored parsed, so the file reads `"importWidth": 800` rather than a
759
+ // stringly-typed `"800"`; `read` stringifies it back for the wire.
760
+ else merged.importWidth = coerceImportWidth(value) ?? UNSPLASH_DEFAULT_IMPORT_WIDTH;
761
+ next.unsplash = merged;
762
+ break;
763
+ }
764
+ default: {
765
+ (next as Record<string, unknown>)[key] = value;
766
+ break;
767
+ }
768
+ }
769
+ }
770
+
771
+ if (next.unsplash) {
772
+ // Never persist the secret through this path. Copied rather than deleted
773
+ // in place, because `next.unsplash` can still be `current`'s own object.
774
+ const { accessKey: _secret, ...rest } = next.unsplash;
775
+ next.unsplash = rest;
776
+ }
777
+ return next;
778
+ }