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,808 @@
1
+ import { directWrite, type TextWriter } from './text-writes.ts';
2
+ import type { AstroIntegrationLogger } from 'astro';
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises';
6
+ import { extname, join, relative, resolve, sep } from 'node:path';
7
+ import { parseEntry } from '../patcher/frontmatter.ts';
8
+ import {
9
+ addCollection,
10
+ addField,
11
+ readCollectionBlocks,
12
+ removeField,
13
+ setSchemaForm,
14
+ updateField,
15
+ type CollectionBlock,
16
+ type SchemaField,
17
+ } from '../patcher/content-config.ts';
18
+ import type {
19
+ CollectionApplyResponse,
20
+ CollectionCreateRequest,
21
+ CollectionEntriesRequest,
22
+ CollectionEntryItem,
23
+ CollectionOpenRequest,
24
+ CollectionPageEditingRequest,
25
+ CollectionPageEditingResponse,
26
+ CollectionRefusal,
27
+ CollectionSchemaApplyRequest,
28
+ CollectionSummary,
29
+ FieldDescriptor,
30
+ FieldOverride,
31
+ FieldType,
32
+ } from '../shared/protocol.ts';
33
+ import type {
34
+ EntryCollectionInfo,
35
+ EntryEditorOptions,
36
+ EntrySchemaProvider,
37
+ } from './content-config.ts';
38
+ import {
39
+ MAX_ENTRIES_LISTED,
40
+ inContentRoots,
41
+ listEntryFiles,
42
+ } from './collection-entries.ts';
43
+ import type { DetailRoutes } from './entry-detect.ts';
44
+ import { launchInEditor } from './editor.ts';
45
+ import { ENTRY_EXTENSIONS } from './entry-routes.ts';
46
+ import type { OptionsResolver, StoredOptions } from './options.ts';
47
+ import { insideRoot } from './paths.ts';
48
+ import type { Route, RouteResult } from './router.ts';
49
+ import { FIELD_TYPES, inferFields, zodToFields } from './schema-introspect.ts';
50
+ import { readStoredOptions, saveStoredOptions } from './settings.ts';
51
+
52
+ /**
53
+ * The collection-designer route group (`/collections`,
54
+ * `/collection/schema/apply`, `/collection/create`) — the Collections tab's
55
+ * server side. A feature route module: it exports a `Route[]` the middleware
56
+ * concatenates, so everything collection-shaped lives here.
57
+ *
58
+ * **This is the third class of write in the codebase, and the widest.** The
59
+ * others target content (confined by `paths.ts::validateEditablePath`) or a fixed
60
+ * settings file. This one patches the project's own `src/content.config.ts` —
61
+ * TypeScript the dev server executes. Four rules hold it in:
62
+ *
63
+ * 1. **The path is discovered server-side**, from `EntrySchemaProvider.configPath`
64
+ * (the conventional candidates, or `entryEditor.configPath` from the config).
65
+ * A request names a *collection*, never a path, so there is no path to
66
+ * smuggle. `configTarget` re-checks the resolved realpath is inside the
67
+ * project root and carries a config-shaped extension anyway — the same
68
+ * fixed-target reasoning `settings.ts` documents, plus a belt.
69
+ * 2. **Every string that reaches generated source is validated**, not escaped:
70
+ * collection and field names must be plain identifiers, a directory and a glob
71
+ * pattern must match a conservative character class. A name that would need
72
+ * quoting is refused instead of quoted, which keeps every expression this
73
+ * module writes a shape it can read back.
74
+ * 3. **Writes are etag-guarded and all-or-nothing.** Each field edit is applied
75
+ * to one in-memory copy and the file is written once at the end, so a refusal
76
+ * anywhere leaves the config untouched and a stale panel fails safe. Same
77
+ * property `/apply` has.
78
+ * 4. **`schemaEditor: false` refuses before touching the filesystem**, so a
79
+ * project can keep the rest of the tool and forbid schema writes outright.
80
+ *
81
+ * The editor half of a field (widget, label, hidden) is not a schema write at
82
+ * all: it goes to `.astro-dev-edit.json` through `saveStoredOptions`. The two
83
+ * stores are deliberately visible as two in the response, because one is
84
+ * committed source and the other is local.
85
+ */
86
+
87
+ export interface SchemaRouteDeps {
88
+ writeText?: TextWriter;
89
+ logger: AstroIntegrationLogger;
90
+ /** Project root (fsPath). */
91
+ root: string;
92
+ /** Live options — `entryEditor` gates the group, `schemaEditor` the writes,
93
+ * and `contentRoots` confines a new collection's directory. */
94
+ optionsResolver: OptionsResolver;
95
+ /** Collection/schema lookup. Null → the panel is told there is no config. */
96
+ schemaProvider: EntrySchemaProvider | null;
97
+ /** Which dynamic route renders which collection, for the page-editing status.
98
+ * Null → the panel shows no detail route rather than a wrong one. */
99
+ detailRoutes: DetailRoutes | null;
100
+ }
101
+
102
+ /** Extensions a content config may have — the tail of `CONFIG_CANDIDATES`. */
103
+ const CONFIG_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
104
+
105
+ /** A repo-relative directory, conservatively. No quotes, no `..`, no backslash —
106
+ * it is written verbatim into a single-quoted string in generated source. */
107
+ const DIR_RE = /^[A-Za-z0-9_\-./]+$/;
108
+
109
+ /** A glob pattern, conservatively. Same reason as {@link DIR_RE}. */
110
+ const PATTERN_RE = /^[A-Za-z0-9_\-./*{}[\],!()]+$/;
111
+
112
+ /** Frontmatter keys tried, in order, for an entry's display title. */
113
+ const TITLE_KEYS = ['title', 'name', 'heading', 'label'];
114
+
115
+ function sha256(text: string): string {
116
+ return createHash('sha256').update(text, 'utf8').digest('hex');
117
+ }
118
+
119
+ /** HTTP status for a designer refusal. */
120
+ const STATUS: Record<CollectionRefusal, number> = {
121
+ conflict: 409,
122
+ disabled: 403,
123
+ unrecognized: 422,
124
+ missing: 422,
125
+ exists: 409,
126
+ unsupported: 422,
127
+ };
128
+
129
+ function refuse(code: CollectionRefusal, error: string): RouteResult {
130
+ const body: CollectionApplyResponse = {
131
+ ok: false,
132
+ schemaWritten: false,
133
+ overridesWritten: false,
134
+ error,
135
+ code,
136
+ };
137
+ return { status: STATUS[code], body };
138
+ }
139
+
140
+ export function createSchemaRoutes(deps: SchemaRouteDeps): Route[] {
141
+ const { logger, root, optionsResolver, schemaProvider, detailRoutes } = deps;
142
+ const writeText: TextWriter = deps.writeText ?? directWrite;
143
+
144
+ /** The gate every route in this group starts at. */
145
+ async function gate(): Promise<{
146
+ enabled: boolean;
147
+ schemaEditor: boolean;
148
+ contentRoots: string[];
149
+ }> {
150
+ const { options } = await optionsResolver.resolve();
151
+ return {
152
+ enabled: options.entryEditor !== false,
153
+ schemaEditor: options.schemaEditor,
154
+ contentRoots: options.contentRoots,
155
+ };
156
+ }
157
+
158
+ let rootRealCache: string | null = null;
159
+ async function rootReal(): Promise<string> {
160
+ rootRealCache ??= await realpath(root);
161
+ return rootRealCache;
162
+ }
163
+
164
+ /**
165
+ * The content config this group may read and write, or null when there isn't
166
+ * one. Discovered server-side; the confinement check is defence in depth
167
+ * against a hand-written `entryEditor.configPath` rather than against a
168
+ * request, which never carries a path.
169
+ */
170
+ async function configTarget(): Promise<{ rel: string; abs: string } | null> {
171
+ const rel = (await schemaProvider?.configPath()) ?? null;
172
+ if (!rel) return null;
173
+ const abs = resolve(root, rel);
174
+ if (!CONFIG_EXTENSIONS.includes(extname(abs))) return null;
175
+ try {
176
+ const real = await realpath(abs);
177
+ if (!insideRoot(await rootReal(), real)) return null;
178
+ return { rel, abs: real };
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /** Entry files in a collection's directory. Counting only — nothing here is
185
+ * ever opened. */
186
+ async function countEntries(dir: string): Promise<{ exists: boolean; count: number }> {
187
+ const abs = resolve(root, dir);
188
+ if (!insideRoot(root, abs) || !existsSync(abs)) return { exists: false, count: 0 };
189
+ try {
190
+ const files = await readdir(abs, { recursive: true });
191
+ return {
192
+ exists: true,
193
+ count: files.filter((f) => ENTRY_EXTENSIONS.some((e) => String(f).endsWith(e))).length,
194
+ };
195
+ } catch {
196
+ return { exists: true, count: 0 };
197
+ }
198
+ }
199
+
200
+ /** The panel's row for one collection: its live fields (schema-derived when the
201
+ * schema resolved) joined to what the config source actually says. */
202
+ async function summarize(
203
+ info: EntryCollectionInfo,
204
+ block: CollectionBlock | undefined,
205
+ /** The stored overrides for this collection, to tell apart what the panel
206
+ * wrote from what the project's config owns. */
207
+ storedFields: Record<string, FieldOverride>,
208
+ ): Promise<CollectionSummary> {
209
+ // Read off the config layer directly rather than inferred by comparing
210
+ // effective against stored: a config and a stored value that agree are
211
+ // indistinguishable that way, and a switch the panel draws as writable while
212
+ // resolution ignores it is worse than one drawn locked.
213
+ const pageEditingLocked =
214
+ optionsResolver.entryEditorConfig()?.collections?.[info.collection]?.pageEditing !==
215
+ undefined;
216
+ const { exists, count } = await countEntries(info.dir);
217
+ // No resolvable schema: fall back to the field names the *source* names, so
218
+ // the row isn't empty. Types are unknown, which `json` is the honest answer
219
+ // for — the same degradation the entry panel makes — and `fieldSource` tells
220
+ // the panel to say where the list came from.
221
+ const derived = info.schema ? zodToFields(info.schema) : null;
222
+ const fields: FieldDescriptor[] =
223
+ derived ?? inferFields(Object.fromEntries((block?.fields ?? []).map((f) => [f.name, null])));
224
+ return {
225
+ name: info.collection,
226
+ dir: info.dir,
227
+ dirExists: exists,
228
+ entryCount: count,
229
+ fields,
230
+ fieldSource: derived ? 'schema' : 'source',
231
+ expressions: Object.fromEntries((block?.fields ?? []).map((f) => [f.name, f.expr])),
232
+ schemaForm: block?.schemaForm ?? null,
233
+ ...(block?.unrecognized
234
+ ? { unrecognized: block.unrecognized }
235
+ : block
236
+ ? {}
237
+ : { unrecognized: 'this collection is not declared in the content config' }),
238
+ registered: block?.registered ?? false,
239
+ ...(block ? { configLine: block.line } : {}),
240
+ overrides: info.fieldConfig,
241
+ // The effective override merges config over store, config winning key by
242
+ // key. Anything that doesn't match the store is therefore the config's, and
243
+ // storing a value for it from the panel would do nothing.
244
+ lockedFields: Object.entries(info.fieldConfig)
245
+ .filter(([name, effective]) => !sameOverride(effective, storedFields[name]))
246
+ .map(([name]) => name),
247
+ pageEditing: info.pageEditing === true,
248
+ pageEditingLocked,
249
+ // Null is a real answer, not a missing one: a data collection no dynamic
250
+ // route renders has no detail route, and the panel says so rather than
251
+ // implying the switch will produce a button somewhere.
252
+ detailRoute: (await detailRoutes?.patternFor(info.collection)) ?? null,
253
+ };
254
+ }
255
+
256
+ /** Read the config source with its etag, or the refusal that stands in for it. */
257
+ async function readConfig(): Promise<
258
+ { ok: true; rel: string; abs: string; source: string; etag: string } | RouteResult
259
+ > {
260
+ const target = await configTarget();
261
+ if (!target) {
262
+ return refuse(
263
+ 'missing',
264
+ 'This project has no content config the designer can read. Create src/content.config.ts first.',
265
+ );
266
+ }
267
+ const source = await readFile(target.abs, 'utf8');
268
+ return { ok: true, ...target, source, etag: sha256(source) };
269
+ }
270
+
271
+ return [
272
+ // The Collections tab's read: every collection, its fields, and whether the
273
+ // designer can patch its schema.
274
+ {
275
+ method: 'POST',
276
+ path: '/collections',
277
+ maxBytes: 1024,
278
+ label: 'collections list',
279
+ handler: async () => {
280
+ const { enabled, schemaEditor } = await gate();
281
+ if (!enabled) {
282
+ return {
283
+ status: 403,
284
+ body: { error: 'the entry editor is disabled by configuration', code: 'disabled' },
285
+ };
286
+ }
287
+ const target = await configTarget();
288
+ let source: string | null = null;
289
+ if (target) {
290
+ try {
291
+ source = await readFile(target.abs, 'utf8');
292
+ } catch {
293
+ source = null;
294
+ }
295
+ }
296
+ const blocks = source ? readCollectionBlocks(source) : [];
297
+ const infos = (await schemaProvider?.listCollections()) ?? [];
298
+ const storedOptions: StoredOptions = await readStoredOptions(root).catch(() => ({}));
299
+ const stored: EntryEditorOptions = storedOptions.entryEditor || {};
300
+ const collections: CollectionSummary[] = [];
301
+ for (const info of infos) {
302
+ collections.push(
303
+ await summarize(
304
+ info,
305
+ blocks.find((b) => b.name === info.collection),
306
+ stored.collections?.[info.collection]?.fields ?? {},
307
+ ),
308
+ );
309
+ }
310
+ // A collection the config *declares* but the provider couldn't report —
311
+ // which is what happens when the config module fails to load at all, and
312
+ // is exactly when the designer is most useful. Listing it from the source
313
+ // alone beats letting it vanish from the panel with no explanation.
314
+ const covered = new Set(collections.map((c) => c.name));
315
+ for (const block of blocks) {
316
+ if (covered.has(block.name)) continue;
317
+ collections.push(
318
+ await summarize(
319
+ {
320
+ collection: block.name,
321
+ dir: `src/content/${block.name}`,
322
+ schema: null,
323
+ // The provider returns nothing for this name only when the
324
+ // config module failed to load *and* nothing configures it
325
+ // explicitly — so the stored layer is the whole answer here.
326
+ pageEditing: stored.collections?.[block.name]?.pageEditing === true,
327
+ fieldConfig: {},
328
+ },
329
+ block,
330
+ {},
331
+ ),
332
+ );
333
+ }
334
+ return {
335
+ status: 200,
336
+ body: {
337
+ configPath: target?.rel ?? null,
338
+ etag: source === null ? null : sha256(source),
339
+ schemaEditor,
340
+ collections,
341
+ },
342
+ };
343
+ },
344
+ },
345
+
346
+ // Schema edits and/or editor overrides for one collection. Schema first, so a
347
+ // refused patch never leaves overrides pointing at fields that don't exist.
348
+ {
349
+ method: 'POST',
350
+ path: '/collection/schema/apply',
351
+ maxBytes: 64 * 1024,
352
+ label: 'collection schema apply',
353
+ handler: async (body) => {
354
+ const { enabled, schemaEditor } = await gate();
355
+ if (!enabled) return refuse('disabled', 'The entry editor is disabled by configuration.');
356
+ const req = (body ?? {}) as CollectionSchemaApplyRequest;
357
+ if (!req.collection || typeof req.collection !== 'string') {
358
+ throw new Error('collection is required');
359
+ }
360
+
361
+ const edits = req.schema ?? {};
362
+ if (edits.form !== undefined && edits.form !== 'object' && edits.form !== 'function') {
363
+ throw new Error('schema.form must be "object" or "function"');
364
+ }
365
+ const hasSchemaEdits =
366
+ (edits.add?.length ?? 0) + (edits.update?.length ?? 0) + (edits.remove?.length ?? 0) > 0 ||
367
+ edits.form !== undefined;
368
+ const hasOverrides = req.overrides && Object.keys(req.overrides).length > 0;
369
+ if (!hasSchemaEdits && !hasOverrides) {
370
+ return { status: 400, body: { error: 'nothing to save' } };
371
+ }
372
+
373
+ let schemaWritten = false;
374
+ let etag: string | undefined;
375
+ if (hasSchemaEdits) {
376
+ if (!schemaEditor) {
377
+ return refuse(
378
+ 'disabled',
379
+ 'Schema editing is off. Turn on "Schema editing" to let the designer write your content config.',
380
+ );
381
+ }
382
+ const config = await readConfig();
383
+ if (!('ok' in config)) return config;
384
+ if (typeof req.etag !== 'string' || req.etag !== config.etag) {
385
+ return refuse(
386
+ 'conflict',
387
+ `${config.rel} changed on disk since this panel read it. Reopen the tab and try again.`,
388
+ );
389
+ }
390
+
391
+ // One in-memory copy, verified all the way through, written once.
392
+ let next = config.source;
393
+ // The form goes first: turning image support on and adding the image
394
+ // field it was turned on for arrive as a single save, and the field
395
+ // can't render until the helper is in scope. A form already matching
396
+ // is a no-op, so an unchanged switch costs nothing.
397
+ if (edits.form !== undefined) {
398
+ const step = setSchemaForm(next, req.collection, edits.form);
399
+ if (!step.ok) return refuse(step.code, step.error);
400
+ next = step.newSource;
401
+ }
402
+ for (const name of edits.remove ?? []) {
403
+ const step = removeField(next, req.collection, String(name));
404
+ if (!step.ok) return refuse(step.code, step.error);
405
+ next = step.newSource;
406
+ }
407
+ for (const raw of edits.update ?? []) {
408
+ const field = coerceField(raw);
409
+ if (!field.ok) return refuse('unsupported', field.error);
410
+ const step = updateField(next, req.collection, field.field);
411
+ if (!step.ok) return refuse(step.code, step.error);
412
+ next = step.newSource;
413
+ }
414
+ for (const raw of edits.add ?? []) {
415
+ const field = coerceField(raw);
416
+ if (!field.ok) return refuse('unsupported', field.error);
417
+ const step = addField(next, req.collection, field.field);
418
+ if (!step.ok) return refuse(step.code, step.error);
419
+ next = step.newSource;
420
+ }
421
+
422
+ if (next !== config.source) {
423
+ await writeText(config.abs, next, config.source);
424
+ logger.info(`${req.collection} schema updated -> ${config.rel}`);
425
+ etag = sha256(next);
426
+ }
427
+ schemaWritten = next !== config.source;
428
+ }
429
+
430
+ let overridesWritten = false;
431
+ let error: string | undefined;
432
+ if (hasOverrides) {
433
+ const result = await writeOverrides(req.collection, req.overrides!);
434
+ if (result.ok) overridesWritten = result.changed;
435
+ else error = result.error;
436
+ }
437
+
438
+ const response: CollectionApplyResponse = {
439
+ ok: !error,
440
+ schemaWritten,
441
+ overridesWritten,
442
+ ...(etag ? { etag } : {}),
443
+ ...(error ? { error, code: 'unsupported' as const } : {}),
444
+ };
445
+ return { status: error ? 422 : 200, body: response };
446
+ },
447
+ },
448
+
449
+ // The Items view's read: one collection's entry files. Frontmatter only — a
450
+ // listing never reads a body — and capped, with the cap reported rather than
451
+ // silently truncating.
452
+ {
453
+ method: 'POST',
454
+ path: '/collection/entries',
455
+ maxBytes: 1024,
456
+ label: 'collection entries',
457
+ handler: async (body) => {
458
+ const { enabled, contentRoots } = await gate();
459
+ if (!enabled) return refuse('disabled', 'The entry editor is disabled by configuration.');
460
+ const { collection } = (body ?? {}) as CollectionEntriesRequest;
461
+ if (!collection || typeof collection !== 'string') {
462
+ throw new Error('collection is required');
463
+ }
464
+ const info = await schemaProvider?.forCollection(collection);
465
+ // A collection the config declares but the provider can't resolve (a
466
+ // config that fails to load) still has entries on disk; fall back to the
467
+ // conventional directory rather than answering "no such collection".
468
+ const dir = info?.dir ?? `src/content/${collection}`;
469
+ const dirAbs = resolve(root, dir);
470
+ if (!inContentRoots(root, dirAbs, contentRoots)) {
471
+ return refuse('unsupported', `${dir} is outside the editable content roots.`);
472
+ }
473
+ if (!existsSync(dirAbs)) {
474
+ return { status: 200, body: { collection, dir, entries: [] } };
475
+ }
476
+
477
+ const { options } = await optionsResolver.resolve();
478
+ const extensions = ENTRY_EXTENSIONS.filter((e) => options.editableExtensions.includes(e));
479
+ const { names, truncated } = await listEntryFiles(dirAbs, extensions);
480
+ const entries: CollectionEntryItem[] = [];
481
+ for (const name of names) {
482
+ entries.push(await describeEntry(dir, dirAbs, name));
483
+ }
484
+ entries.sort((a, b) => b.mtime - a.mtime);
485
+ if (truncated) {
486
+ logger.info(`${collection}: listing the first ${MAX_ENTRIES_LISTED} entries`);
487
+ }
488
+ return {
489
+ status: 200,
490
+ body: { collection, dir, entries, ...(truncated ? { truncated: true } : {}) },
491
+ };
492
+ },
493
+ },
494
+
495
+ // Switch one collection's in-page entry drawer on or off. Its own route
496
+ // rather than a corner of /collection/schema/apply, whose `overrides` are
497
+ // keyed per field while this is one flag about the collection — and it saves
498
+ // on the flip, because the list view it is drawn in has no Save button.
499
+ //
500
+ // Gated on `entryEditor` and deliberately NOT on `schemaEditor`: this writes
501
+ // `.astro-dev-edit.json`, the local gitignored half, exactly as a widget or
502
+ // label does. No committed source is touched.
503
+ {
504
+ method: 'POST',
505
+ path: '/collection/page-editing',
506
+ maxBytes: 1024,
507
+ label: 'collection page editing',
508
+ handler: async (body) => {
509
+ const { enabled: on } = await gate();
510
+ if (!on) return refuse('disabled', 'The entry editor is disabled by configuration.');
511
+ const { collection, enabled } = (body ?? {}) as CollectionPageEditingRequest;
512
+ if (!collection || typeof collection !== 'string') {
513
+ throw new Error('collection is required');
514
+ }
515
+ if (typeof enabled !== 'boolean') throw new Error('enabled must be a boolean');
516
+
517
+ // Refusing to store a value resolution would ignore, the same stance
518
+ // `/settings` takes for a config-owned option and `writeAccessKey` takes
519
+ // for a config-supplied key.
520
+ if (
521
+ optionsResolver.entryEditorConfig()?.collections?.[collection]?.pageEditing !== undefined
522
+ ) {
523
+ return refuse(
524
+ 'disabled',
525
+ `Page editing for ${collection} is set in astro.config.mjs, which takes precedence. ` +
526
+ 'Remove it there to manage it from this panel.',
527
+ );
528
+ }
529
+
530
+ const result = await writePageEditing(collection, enabled);
531
+ if (!result.ok) return refuse('unsupported', result.error);
532
+ const response: CollectionPageEditingResponse = { ok: true, pageEditing: enabled };
533
+ return { status: 200, body: response };
534
+ },
535
+ },
536
+
537
+ // Launch the editor on the content config. Read-only, and the *only* path it
538
+ // can reach is the one this group discovered — the request carries a
539
+ // collection name at most, so there is nothing to confine beyond what
540
+ // `configTarget` already did.
541
+ {
542
+ method: 'POST',
543
+ path: '/collection/open',
544
+ maxBytes: 1024,
545
+ label: 'collection open',
546
+ handler: async (body) => {
547
+ const { enabled } = await gate();
548
+ const { options } = await optionsResolver.resolve();
549
+ if (!enabled || !options.openInEditor) {
550
+ return refuse('disabled', 'Open-in-editor is disabled by configuration.');
551
+ }
552
+ const config = await readConfig();
553
+ if (!('ok' in config)) return config;
554
+ const { collection } = (body ?? {}) as CollectionOpenRequest;
555
+ const block = collection
556
+ ? readCollectionBlocks(config.source).find((b) => b.name === collection)
557
+ : undefined;
558
+ await launchInEditor(block ? `${config.abs}:${block.line}:1` : config.abs);
559
+ return { status: 200, body: { ok: true } };
560
+ },
561
+ },
562
+
563
+ // Append a collection: its defineCollection block, its registry entry, and
564
+ // its entry directory.
565
+ {
566
+ method: 'POST',
567
+ path: '/collection/create',
568
+ maxBytes: 64 * 1024,
569
+ label: 'collection create',
570
+ handler: async (body) => {
571
+ const { enabled, schemaEditor, contentRoots } = await gate();
572
+ if (!enabled) return refuse('disabled', 'The entry editor is disabled by configuration.');
573
+ if (!schemaEditor) {
574
+ return refuse(
575
+ 'disabled',
576
+ 'Schema editing is off. Turn on "Schema editing" to create collections from here.',
577
+ );
578
+ }
579
+ const req = (body ?? {}) as CollectionCreateRequest;
580
+ const name = String(req.name ?? '');
581
+ const dir = String(req.dir || `src/content/${name}`);
582
+ const pattern = req.pattern ? String(req.pattern) : undefined;
583
+
584
+ if (!DIR_RE.test(dir) || dir.includes('..')) {
585
+ return refuse('unsupported', `"${dir}" is not a usable collection directory.`);
586
+ }
587
+ if (pattern && !PATTERN_RE.test(pattern)) {
588
+ return refuse('unsupported', `"${pattern}" is not a usable glob pattern.`);
589
+ }
590
+ // The directory is client-supplied, so confine it exactly as an edit path
591
+ // would be: inside the root, and inside a configured content root.
592
+ const dirAbs = resolve(root, dir);
593
+ const relDir = relative(root, dirAbs);
594
+ const inRoot = contentRoots.some(
595
+ (cr) => relDir === cr || relDir.startsWith(cr.endsWith(sep) ? cr : cr + sep),
596
+ );
597
+ if (!insideRoot(root, dirAbs) || !inRoot) {
598
+ return refuse('unsupported', `${dir} is outside the editable content roots.`);
599
+ }
600
+
601
+ const config = await readConfig();
602
+ if (!('ok' in config)) return config;
603
+ if (typeof req.etag !== 'string' || req.etag !== config.etag) {
604
+ return refuse(
605
+ 'conflict',
606
+ `${config.rel} changed on disk since this panel read it. Reopen the tab and try again.`,
607
+ );
608
+ }
609
+
610
+ const fields: SchemaField[] = [];
611
+ for (const raw of req.fields ?? []) {
612
+ const field = coerceField(raw);
613
+ if (!field.ok) return refuse('unsupported', field.error);
614
+ fields.push(field.field);
615
+ }
616
+
617
+ if (req.schemaForm !== undefined && req.schemaForm !== 'object' && req.schemaForm !== 'function') {
618
+ throw new Error('schemaForm must be "object" or "function"');
619
+ }
620
+ const patched = addCollection(config.source, {
621
+ name,
622
+ dir,
623
+ ...(pattern ? { pattern } : {}),
624
+ ...(req.schemaForm ? { schemaForm: req.schemaForm } : {}),
625
+ fields,
626
+ });
627
+ if (!patched.ok) return refuse(patched.code, patched.error);
628
+
629
+ // Directory first: a registered collection whose directory is missing is
630
+ // a build error, while a directory with no collection is inert.
631
+ await mkdir(dirAbs, { recursive: true });
632
+ await writeText(config.abs, patched.newSource, config.source);
633
+ logger.info(`collection created -> ${name} (${dir})`);
634
+ return { status: 200, body: { name, dir, etag: sha256(patched.newSource) } };
635
+ },
636
+ },
637
+ ];
638
+
639
+ /**
640
+ * Merge editor-only overrides into the settings file. Never touches the config:
641
+ * these are widget/label/hidden, which only the entry drawer reads.
642
+ *
643
+ * An override that says nothing (no widget, no label, `hidden: false`) is
644
+ * *removed* rather than stored, so reverting a row in the panel leaves the file
645
+ * as it was instead of accumulating no-op entries.
646
+ */
647
+ /**
648
+ * Switch a collection's page editing on or off in the stored options.
649
+ *
650
+ * Shaped exactly like {@link writeOverrides} — read-modify-write at every
651
+ * level, never replacing a sibling — with one addition: **off is written by
652
+ * removing the key**, not by storing `false`. Off is already the default, so a
653
+ * stored `false` would be a husk that says nothing, and the collection entry
654
+ * itself is dropped when nothing else is left in it.
655
+ */
656
+ async function writePageEditing(
657
+ collection: string,
658
+ enabled: boolean,
659
+ ): Promise<{ ok: true; changed: boolean } | { ok: false; error: string }> {
660
+ try {
661
+ const stored = await readStoredOptions(root);
662
+ const detail: EntryEditorOptions = stored.entryEditor || {};
663
+ const collections = { ...detail.collections };
664
+ const { pageEditing: _was, ...rest } = { ...collections[collection] };
665
+ if (enabled) collections[collection] = { ...rest, pageEditing: true };
666
+ else if (Object.keys(rest).length > 0) collections[collection] = rest;
667
+ else delete collections[collection];
668
+
669
+ const before = JSON.stringify(detail.collections ?? {});
670
+ if (before === JSON.stringify(collections)) return { ok: true, changed: false };
671
+
672
+ await saveStoredOptions(root, {
673
+ ...stored,
674
+ entryEditor: { ...detail, collections },
675
+ }, deps.writeText);
676
+ logger.info(`page editing ${enabled ? 'on' : 'off'}: ${collection}`);
677
+ return { ok: true, changed: true };
678
+ } catch (err) {
679
+ return { ok: false, error: `could not save page editing: ${String(err)}` };
680
+ }
681
+ }
682
+
683
+ async function writeOverrides(
684
+ collection: string,
685
+ patch: Record<string, FieldOverride | null>,
686
+ ): Promise<{ ok: true; changed: boolean } | { ok: false; error: string }> {
687
+ for (const [field, value] of Object.entries(patch)) {
688
+ const widget = value?.widget;
689
+ if (widget !== undefined && !FIELD_TYPES.includes(widget)) {
690
+ return { ok: false, error: `"${widget}" is not a widget this editor knows (${field}).` };
691
+ }
692
+ }
693
+ try {
694
+ const stored = await readStoredOptions(root);
695
+ // `false` here is the config-level kill switch, not a detail — the stored
696
+ // document keeps the detail beside its own on/off flag (see StoredOptions).
697
+ const detail: EntryEditorOptions = stored.entryEditor || {};
698
+ const collections = { ...detail.collections };
699
+ const one = { ...collections[collection] };
700
+ const fields = { ...one.fields };
701
+ for (const [field, value] of Object.entries(patch)) {
702
+ const merged = normalizeOverride({ ...fields[field], ...(value ?? {}) });
703
+ if (value === null || !merged) delete fields[field];
704
+ else fields[field] = merged;
705
+ }
706
+ const before = JSON.stringify(one.fields ?? {});
707
+ if (before === JSON.stringify(fields)) return { ok: true, changed: false };
708
+
709
+ if (Object.keys(fields).length > 0) {
710
+ collections[collection] = { ...one, fields };
711
+ } else {
712
+ // No overrides left. Keep the entry only if it carries something else
713
+ // (a configured dir or extension); otherwise drop it, so reverting every
714
+ // field in the panel leaves the file as it was rather than holding an
715
+ // empty husk.
716
+ const { fields: _drop, ...rest } = one;
717
+ if (Object.keys(rest).length > 0) collections[collection] = rest;
718
+ else delete collections[collection];
719
+ }
720
+
721
+ await saveStoredOptions(root, {
722
+ ...stored,
723
+ entryEditor: { ...detail, collections },
724
+ }, deps.writeText);
725
+ logger.info(`field overrides saved: ${collection}`);
726
+ return { ok: true, changed: true };
727
+ } catch (err) {
728
+ return { ok: false, error: `could not save field overrides: ${String(err)}` };
729
+ }
730
+ }
731
+ }
732
+
733
+ /** One entry's listing row. Reads the file's frontmatter only; a parse failure
734
+ * degrades to "no title" rather than dropping the entry, since an entry with
735
+ * broken YAML is exactly one the user needs to find. */
736
+ async function describeEntry(
737
+ dir: string,
738
+ dirAbs: string,
739
+ name: string,
740
+ ): Promise<CollectionEntryItem> {
741
+ const abs = join(dirAbs, name);
742
+ const posix = name.split(sep).join('/');
743
+ const slug = posix.replace(/\.(md|mdx)$/i, '');
744
+ let title: string | null = null;
745
+ let draft = false;
746
+ let mtime = 0;
747
+ try {
748
+ mtime = (await stat(abs)).mtimeMs;
749
+ const parsed = parseEntry(await readFile(abs, 'utf8'));
750
+ for (const key of TITLE_KEYS) {
751
+ const v = parsed.data[key];
752
+ if (typeof v === 'string' && v.trim()) {
753
+ title = v.trim();
754
+ break;
755
+ }
756
+ }
757
+ draft = parsed.data.draft === true;
758
+ } catch {
759
+ // Unreadable or unparseable — still listed, just without its metadata.
760
+ }
761
+ return { file: `${dir}/${posix}`, slug, title, mtime, draft };
762
+ }
763
+
764
+ /** Whether two overrides say the same thing. Absent counts as empty. */
765
+ function sameOverride(a: FieldOverride, b: FieldOverride | undefined): boolean {
766
+ const norm = (o: FieldOverride | undefined): string =>
767
+ JSON.stringify(normalizeOverride(o ?? {}) ?? {});
768
+ return norm(a) === norm(b);
769
+ }
770
+
771
+ /** Strip an override down to what it actually says; null when it says nothing. */
772
+ function normalizeOverride(o: FieldOverride): FieldOverride | null {
773
+ const out: FieldOverride = {};
774
+ if (o.widget) out.widget = o.widget;
775
+ if (o.label) out.label = o.label;
776
+ if (o.hidden) out.hidden = true;
777
+ return Object.keys(out).length > 0 ? out : null;
778
+ }
779
+
780
+ /**
781
+ * Validate an incoming field spec. Names must be plain identifiers and types must
782
+ * be known — the patcher refuses anything else anyway, but refusing here keeps the
783
+ * message about the request rather than about the source.
784
+ */
785
+ function coerceField(
786
+ raw: unknown,
787
+ ): { ok: true; field: SchemaField } | { ok: false; error: string } {
788
+ const r = (raw ?? {}) as Partial<SchemaField>;
789
+ const name = String(r.name ?? '');
790
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
791
+ return { ok: false, error: `"${name}" is not a usable field name.` };
792
+ }
793
+ const type = r.type as FieldType;
794
+ if (!FIELD_TYPES.includes(type)) {
795
+ return { ok: false, error: `"${String(r.type)}" is not a field type this editor knows.` };
796
+ }
797
+ const options = Array.isArray(r.options) ? r.options.map((o) => String(o)).filter(Boolean) : undefined;
798
+ return {
799
+ ok: true,
800
+ field: {
801
+ name,
802
+ type,
803
+ required: r.required === true,
804
+ ...(r.defaultValue !== undefined ? { defaultValue: r.defaultValue } : {}),
805
+ ...(options ? { options } : {}),
806
+ },
807
+ };
808
+ }