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,1035 @@
1
+ import type {
2
+ FieldType,
3
+ SchemaFieldSpec,
4
+ SchemaForm as WireSchemaForm,
5
+ } from '../shared/protocol.ts';
6
+
7
+ /**
8
+ * Reads and patches a project's `content.config.ts` — the schema half of the
9
+ * collection designer. Pure string-in/string-out, like every other patcher; the
10
+ * route owns all filesystem access.
11
+ *
12
+ * Deliberately **not** a registry `Patcher`: that interface is loc-based
13
+ * (classify/apply against a source annotation), while this targets named
14
+ * collections and named schema keys. It is the same kind of module as
15
+ * `frontmatter.ts`, and shares its contract — the untouched bytes of the file,
16
+ * comments and quoting included, come out exactly as they went in.
17
+ *
18
+ * **No JavaScript parser, by design.** `expression-trace.ts` already records the
19
+ * reasoning: a runtime dependency is not worth taking on for a job whose failure
20
+ * mode must be *refusal* anyway. It applies with more force here, because the
21
+ * target is TypeScript — Vite's re-exported `parseAst` is a JavaScript parser and
22
+ * throws on `import type`, `satisfies`, `as` and type annotations, all of which
23
+ * belong in a real content config.
24
+ *
25
+ * So: a scanner anchored on shapes it can prove, refusing everything else.
26
+ * {@link blankNonCode} makes that safe by blanking every string, template,
27
+ * comment and regex literal to spaces first, so no brace, comma or colon inside
28
+ * a string is ever mistaken for structure. Offsets are preserved, so every span
29
+ * found in the blanked copy indexes the original.
30
+ *
31
+ * The shapes it can prove:
32
+ *
33
+ * - `const <name> = defineCollection({ … schema: z.object({ … }) })`
34
+ * - `const <name> = defineCollection({ … schema: ({ image }) => z.object({ … }) })`
35
+ * - `export const collections = { … }` — the registry a new name is added to
36
+ *
37
+ * Anything else — a schema built by a helper, a spread in the field list, a
38
+ * conditional — is `unrecognized`, and the UI offers "open source" instead of
39
+ * guessing. A refusal is a correct outcome here; a bad guess is not.
40
+ *
41
+ * **{@link renderZodField} is the inverse of `schema-introspect.ts::terminalType`
42
+ * and the two must stay in step** — the same standing invariant `annotate.ts`
43
+ * has against the patcher's loc rules. `tests/content-config-patch.test.ts`
44
+ * guards it with a round trip through the real zod.
45
+ */
46
+
47
+ /** Why a patch was refused.
48
+ *
49
+ * - `unrecognized` — the shape isn't one this module can prove, so it won't try.
50
+ * - `missing` — the named collection, field or registry isn't there.
51
+ * - `exists` — it is already there.
52
+ * - `unsupported` — readable and present, but this edit can't be expressed
53
+ * (an `image()` field on a plain object schema, a `json` widget, a default on
54
+ * a date).
55
+ */
56
+ export type ConfigRefusalCode = 'unrecognized' | 'missing' | 'exists' | 'unsupported';
57
+
58
+ export type ConfigPatchResult =
59
+ | { ok: true; newSource: string }
60
+ | { ok: false; error: string; code: ConfigRefusalCode };
61
+
62
+ /** A field as the designer describes it. The wire shape is the only shape —
63
+ * `protocol.ts` owns it, so a request body needs no translation here. */
64
+ export type SchemaField = SchemaFieldSpec;
65
+
66
+ /** One field as it stands in the source: its key and its zod expression
67
+ * verbatim, so the panel can show what it can't yet model. */
68
+ export interface RawSchemaField {
69
+ name: string;
70
+ expr: string;
71
+ }
72
+
73
+ /** How a collection's `schema:` is written. The wire shape is the only shape,
74
+ * the same way {@link SchemaField} is. */
75
+ export type SchemaForm = WireSchemaForm;
76
+
77
+ export interface CollectionBlock {
78
+ /** The `const` name, which is also the key the registry uses. */
79
+ name: string;
80
+ /** Null when the schema key is absent or its shape wasn't recognized. */
81
+ schemaForm: SchemaForm | null;
82
+ /** Fields in source order. Empty when the schema wasn't recognized. */
83
+ fields: RawSchemaField[];
84
+ /** Why the schema can't be patched, when it can't. */
85
+ unrecognized?: string;
86
+ /** Whether the const appears in `export const collections`. */
87
+ registered: boolean;
88
+ /** 1-based line of the `const <name> = defineCollection(` statement, so the
89
+ * panel's "open source" can jump to the block rather than the file top. */
90
+ line: number;
91
+ }
92
+
93
+ /** The shape {@link addCollection} emits. */
94
+ export interface NewCollection {
95
+ name: string;
96
+ /** Repo-relative entry directory, used as the glob loader's `base`. */
97
+ dir: string;
98
+ /** Glob pattern for the loader. Defaults to `**\/*.md`. */
99
+ pattern?: string;
100
+ /** Which form to write the schema in. Stated, not inferred from the fields:
101
+ * a collection may want `image()` in scope before it has an image field, and
102
+ * the designer's own switch is what says so. Defaults to `object`, and a
103
+ * spec that holds an image field is promoted regardless — that combination
104
+ * cannot compile otherwise. */
105
+ schemaForm?: SchemaForm;
106
+ fields: SchemaField[];
107
+ }
108
+
109
+ // --- Lexing ------------------------------------------------------------------
110
+
111
+ /**
112
+ * The source with every string, template, comment and regex-literal character
113
+ * replaced by a space — newlines kept, so offsets and line numbers are
114
+ * unchanged. Every scan in this module runs against this copy and slices the
115
+ * original, which is what makes plain character matching safe.
116
+ *
117
+ * Templates are blanked whole, `${…}` included. That keeps braces balanced
118
+ * (both the opener and its closer go), and a content config with structure
119
+ * hiding inside a template expression is not a shape this module claims.
120
+ *
121
+ * Exported for the tests, which pin the blanking itself — it is the assumption
122
+ * every other function here rests on.
123
+ */
124
+ export function blankNonCode(source: string): string {
125
+ const out = source.split('');
126
+ let i = 0;
127
+ let prevSig = '';
128
+ const blank = (from: number, to: number): void => {
129
+ for (let k = from; k < to && k < out.length; k++) {
130
+ if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' ';
131
+ }
132
+ };
133
+
134
+ while (i < source.length) {
135
+ const ch = source[i];
136
+ const next = source[i + 1];
137
+
138
+ if (ch === '/' && next === '/') {
139
+ const nl = source.indexOf('\n', i);
140
+ const end = nl === -1 ? source.length : nl;
141
+ blank(i, end);
142
+ i = end;
143
+ continue;
144
+ }
145
+ if (ch === '/' && next === '*') {
146
+ const close = source.indexOf('*/', i + 2);
147
+ const end = close === -1 ? source.length : close + 2;
148
+ blank(i, end);
149
+ i = end;
150
+ continue;
151
+ }
152
+ if (ch === '"' || ch === "'") {
153
+ const end = endOfQuoted(source, i, ch);
154
+ blank(i, end);
155
+ i = end;
156
+ prevSig = 'x';
157
+ continue;
158
+ }
159
+ if (ch === '`') {
160
+ const end = endOfTemplate(source, i);
161
+ blank(i, end);
162
+ i = end;
163
+ prevSig = 'x';
164
+ continue;
165
+ }
166
+ if (ch === '/' && REGEX_START.test(prevSig)) {
167
+ const end = endOfRegex(source, i);
168
+ blank(i, end);
169
+ i = end;
170
+ prevSig = 'x';
171
+ continue;
172
+ }
173
+ if (!/\s/.test(ch)) prevSig = ch;
174
+ i++;
175
+ }
176
+ return out.join('');
177
+ }
178
+
179
+ /** Characters after which a `/` starts a regex literal rather than a division.
180
+ * Empty (start of file) counts, hence the `^$` alternative. */
181
+ const REGEX_START = /^$|^[(,=:[!&|?{};+\-*%~^<>]$/;
182
+
183
+ /** Index just past the closing quote (or end of source). */
184
+ function endOfQuoted(src: string, open: number, quote: string): number {
185
+ let i = open + 1;
186
+ while (i < src.length) {
187
+ const c = src[i];
188
+ if (c === '\\') {
189
+ i += 2;
190
+ continue;
191
+ }
192
+ if (c === quote) return i + 1;
193
+ // An unterminated single-line string: stop at the newline rather than
194
+ // blanking the rest of the file.
195
+ if (c === '\n') return i;
196
+ i++;
197
+ }
198
+ return src.length;
199
+ }
200
+
201
+ /** Index just past the closing backtick, nested templates included. */
202
+ function endOfTemplate(src: string, open: number): number {
203
+ let i = open + 1;
204
+ let depth = 0; // brace depth inside a ${…}
205
+ let inExpr = false;
206
+ while (i < src.length) {
207
+ const c = src[i];
208
+ if (c === '\\') {
209
+ i += 2;
210
+ continue;
211
+ }
212
+ if (!inExpr && c === '$' && src[i + 1] === '{') {
213
+ inExpr = true;
214
+ depth = 1;
215
+ i += 2;
216
+ continue;
217
+ }
218
+ if (inExpr) {
219
+ if (c === '{') depth++;
220
+ else if (c === '}') {
221
+ depth--;
222
+ if (depth === 0) inExpr = false;
223
+ } else if (c === '`') {
224
+ i = endOfTemplate(src, i);
225
+ continue;
226
+ }
227
+ i++;
228
+ continue;
229
+ }
230
+ if (c === '`') return i + 1;
231
+ i++;
232
+ }
233
+ return src.length;
234
+ }
235
+
236
+ /** Index just past a regex literal's flags. */
237
+ function endOfRegex(src: string, open: number): number {
238
+ let i = open + 1;
239
+ let inClass = false;
240
+ while (i < src.length) {
241
+ const c = src[i];
242
+ if (c === '\\') {
243
+ i += 2;
244
+ continue;
245
+ }
246
+ if (c === '\n') return i;
247
+ if (c === '[') inClass = true;
248
+ else if (c === ']') inClass = false;
249
+ else if (c === '/' && !inClass) {
250
+ i++;
251
+ while (i < src.length && /[a-z]/.test(src[i])) i++;
252
+ return i;
253
+ }
254
+ i++;
255
+ }
256
+ return src.length;
257
+ }
258
+
259
+ const CLOSERS: Record<string, string> = { '(': ')', '[': ']', '{': '}' };
260
+
261
+ /** Index of the bracket matching the one at `open` in a **blanked** source, or
262
+ * -1 when unbalanced. */
263
+ function matchBracket(code: string, open: number): number {
264
+ const stack: string[] = [];
265
+ for (let i = open; i < code.length; i++) {
266
+ const c = code[i];
267
+ if (CLOSERS[c]) {
268
+ stack.push(CLOSERS[c]);
269
+ continue;
270
+ }
271
+ if (c === ')' || c === ']' || c === '}') {
272
+ if (stack.pop() !== c) return -1;
273
+ if (stack.length === 0) return i;
274
+ }
275
+ }
276
+ return -1;
277
+ }
278
+
279
+ /** First non-whitespace index at or after `i` (blanked source, so comments are
280
+ * already whitespace). */
281
+ function skipSpace(code: string, i: number, end = code.length): number {
282
+ let k = i;
283
+ while (k < end && /\s/.test(code[k])) k++;
284
+ return k;
285
+ }
286
+
287
+ /** Last index before `end` that isn't whitespace, plus one. */
288
+ function trimEnd(code: string, start: number, end: number): number {
289
+ let k = end;
290
+ while (k > start && /\s/.test(code[k - 1])) k--;
291
+ return k;
292
+ }
293
+
294
+ interface Entry {
295
+ /** Code start of the whole entry (its key). */
296
+ start: number;
297
+ /** Code end of the whole entry, trailing trivia trimmed. */
298
+ end: number;
299
+ /** Parsed key, or null when the entry isn't `key: value`. */
300
+ name: string | null;
301
+ /** Code start of the value expression. */
302
+ valueStart: number;
303
+ }
304
+
305
+ /**
306
+ * Top-level `key: value` entries of the object literal whose `{` is at
307
+ * `braceOpen`. Anything that isn't a plain key (a spread, a computed key, a
308
+ * shorthand, a method) comes back with `name: null` — callers refuse rather than
309
+ * patch a list they can't fully account for.
310
+ */
311
+ function readEntries(code: string, braceOpen: number, braceClose: number): Entry[] {
312
+ const entries: Entry[] = [];
313
+ let depth = 0;
314
+ let segStart = braceOpen + 1;
315
+ const push = (from: number, to: number): void => {
316
+ const start = skipSpace(code, from, to);
317
+ const end = trimEnd(code, start, to);
318
+ if (start >= end) return;
319
+ entries.push({ start, end, ...parseKey(code, start, end) });
320
+ };
321
+ for (let i = braceOpen + 1; i < braceClose; i++) {
322
+ const c = code[i];
323
+ if (CLOSERS[c]) depth++;
324
+ else if (c === ')' || c === ']' || c === '}') depth--;
325
+ else if (c === ',' && depth === 0) {
326
+ push(segStart, i);
327
+ segStart = i + 1;
328
+ }
329
+ }
330
+ push(segStart, braceClose);
331
+ return entries;
332
+ }
333
+
334
+ const IDENT = /^[A-Za-z_$][\w$]*/;
335
+
336
+ function parseKey(code: string, start: number, end: number): { name: string | null; valueStart: number } {
337
+ let i = start;
338
+ let name: string | null = null;
339
+ const quote = code[i];
340
+ if (quote === '"' || quote === "'") {
341
+ // The key text was blanked, so read it from the original later; here only
342
+ // the span matters. Quoted keys are recognized but their name is unknown
343
+ // from the blanked copy — the caller re-reads it.
344
+ const close = code.indexOf(quote, i + 1);
345
+ if (close === -1) return { name: null, valueStart: start };
346
+ name = '';
347
+ i = close + 1;
348
+ } else {
349
+ const m = IDENT.exec(code.slice(i, end));
350
+ if (!m) return { name: null, valueStart: start };
351
+ name = m[0];
352
+ i += m[0].length;
353
+ }
354
+ i = skipSpace(code, i, end);
355
+ if (code[i] !== ':') return { name: null, valueStart: start };
356
+ return { name, valueStart: skipSpace(code, i + 1, end) };
357
+ }
358
+
359
+ // --- Locating ----------------------------------------------------------------
360
+
361
+ interface LocatedBlock extends Omit<CollectionBlock, 'line'> {
362
+ /** First character of the `schema:` value, when the schema was recognized.
363
+ * On the function form this is the `(` of the parameter list. */
364
+ schemaValueStart: number;
365
+ /** Where `z.object` begins — the same offset on both forms, since the
366
+ * function form is exactly the object form with a prefix. The span between
367
+ * this and {@link schemaValueStart} *is* the arrow function's head, which is
368
+ * what makes switching between the forms an insert or a delete of one span
369
+ * rather than a rewrite. */
370
+ zodObjectAt: number;
371
+ /** `{` of the field object, when the schema was recognized. */
372
+ fieldsOpen: number;
373
+ /** matching `}` of the field object. */
374
+ fieldsClose: number;
375
+ /** Entry spans inside the field object. */
376
+ entries: Entry[];
377
+ /** Statement start of the whole `const … = defineCollection(…)`. */
378
+ blockStart: number;
379
+ }
380
+
381
+ const COLLECTION_RE =
382
+ /(^|[\n;])([ \t]*)(export[ \t]+)?const[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=;]*)?=[\s]*defineCollection[\s]*\(/g;
383
+
384
+ interface Located {
385
+ code: string;
386
+ blocks: LocatedBlock[];
387
+ /** `{` and `}` of `export const collections = { … }`, when present. */
388
+ registry: { open: number; close: number; start: number; entries: Entry[] } | null;
389
+ }
390
+
391
+ function locate(source: string): Located {
392
+ const code = blankNonCode(source);
393
+ const blocks: LocatedBlock[] = [];
394
+ const registry = locateRegistry(source, code);
395
+ const registered = new Set(
396
+ (registry?.entries ?? [])
397
+ .map((e) => entryKey(source, code, e))
398
+ .filter((n): n is string => Boolean(n)),
399
+ );
400
+
401
+ COLLECTION_RE.lastIndex = 0;
402
+ let m: RegExpExecArray | null;
403
+ while ((m = COLLECTION_RE.exec(code))) {
404
+ const name = m[4];
405
+ const argOpen = m.index + m[0].length - 1;
406
+ const blockStart = m.index + m[1].length;
407
+ const argClose = matchBracket(code, argOpen);
408
+ const base = { name, registered: registered.has(name), blockStart };
409
+ if (argClose === -1) {
410
+ blocks.push({
411
+ ...base,
412
+ schemaForm: null,
413
+ fields: [],
414
+ unrecognized: 'the defineCollection(…) call is unbalanced',
415
+ schemaValueStart: -1,
416
+ zodObjectAt: -1,
417
+ fieldsOpen: -1,
418
+ fieldsClose: -1,
419
+ entries: [],
420
+ });
421
+ continue;
422
+ }
423
+ blocks.push({ ...base, ...readSchema(source, code, argOpen, argClose) });
424
+ }
425
+ return { code, blocks, registry };
426
+ }
427
+
428
+ type SchemaPart = Omit<LocatedBlock, 'name' | 'registered' | 'blockStart'>;
429
+
430
+ function unreadable(reason: string): SchemaPart {
431
+ return {
432
+ schemaForm: null,
433
+ fields: [],
434
+ unrecognized: reason,
435
+ schemaValueStart: -1,
436
+ zodObjectAt: -1,
437
+ fieldsOpen: -1,
438
+ fieldsClose: -1,
439
+ entries: [],
440
+ };
441
+ }
442
+
443
+ /** The `schema:` value inside a `defineCollection(` argument list. */
444
+ function readSchema(source: string, code: string, argOpen: number, argClose: number): SchemaPart {
445
+ const objOpen = skipSpace(code, argOpen + 1, argClose);
446
+ if (code[objOpen] !== '{') {
447
+ return unreadable('defineCollection() is not called with an object literal');
448
+ }
449
+ const objClose = matchBracket(code, objOpen);
450
+ if (objClose === -1) return unreadable('the collection config object is unbalanced');
451
+
452
+ const schemaEntry = readEntries(code, objOpen, objClose).find(
453
+ (e) => e.name === 'schema' || (e.name === '' && readQuotedKey(source, e.start) === 'schema'),
454
+ );
455
+ if (!schemaEntry) return unreadable('this collection has no schema');
456
+
457
+ // Either `z.object(` directly, or an arrow function returning one — the form
458
+ // that receives Astro's image() helper.
459
+ let form: SchemaForm = 'object';
460
+ let at = schemaEntry.valueStart;
461
+ const arrow = findArrow(code, at, schemaEntry.end);
462
+ if (arrow !== -1) {
463
+ form = 'function';
464
+ at = skipSpace(code, arrow + 2, schemaEntry.end);
465
+ // `=> (z.object({…}))` and `=> ({…})` both start with a paren; only the
466
+ // first is a shape we can prove.
467
+ while (code[at] === '(') {
468
+ const inner = skipSpace(code, at + 1, schemaEntry.end);
469
+ if (code.startsWith('z.object', inner)) at = inner;
470
+ else break;
471
+ }
472
+ }
473
+ if (!code.startsWith('z.object', at)) {
474
+ return unreadable(
475
+ form === 'function'
476
+ ? 'the schema function does not return a plain z.object({…})'
477
+ : 'the schema is not a plain z.object({…})',
478
+ );
479
+ }
480
+ const callOpen = skipSpace(code, at + 'z.object'.length, schemaEntry.end);
481
+ if (code[callOpen] !== '(') return unreadable('z.object is not called');
482
+ const callClose = matchBracket(code, callOpen);
483
+ if (callClose === -1) return unreadable('the z.object(…) call is unbalanced');
484
+ const fieldsOpen = skipSpace(code, callOpen + 1, callClose);
485
+ if (code[fieldsOpen] !== '{') return unreadable('z.object() is not given an object literal');
486
+ const fieldsClose = matchBracket(code, fieldsOpen);
487
+ if (fieldsClose === -1) return unreadable('the schema field list is unbalanced');
488
+
489
+ const entries = readEntries(code, fieldsOpen, fieldsClose);
490
+ const fields: RawSchemaField[] = [];
491
+ for (const e of entries) {
492
+ const name = e.name === '' ? readQuotedKey(source, e.start) : e.name;
493
+ if (!name) {
494
+ return unreadable(
495
+ 'the schema field list holds something other than plain `name: schema` entries',
496
+ );
497
+ }
498
+ e.name = name;
499
+ fields.push({ name, expr: source.slice(e.valueStart, e.end) });
500
+ }
501
+ return {
502
+ schemaForm: form,
503
+ fields,
504
+ schemaValueStart: schemaEntry.valueStart,
505
+ zodObjectAt: at,
506
+ fieldsOpen,
507
+ fieldsClose,
508
+ entries,
509
+ };
510
+ }
511
+
512
+ /** Index of the `=>` that separates an arrow function's params from its body, at
513
+ * the top level of `[start, end)`; -1 when the value isn't an arrow function. */
514
+ function findArrow(code: string, start: number, end: number): number {
515
+ let depth = 0;
516
+ for (let i = start; i < end - 1; i++) {
517
+ const c = code[i];
518
+ if (CLOSERS[c]) depth++;
519
+ else if (c === ')' || c === ']' || c === '}') depth--;
520
+ else if (depth === 0 && c === '=' && code[i + 1] === '>') return i;
521
+ }
522
+ return -1;
523
+ }
524
+
525
+ /** A quoted key's text, read from the original source at a span the blanked copy
526
+ * located. Only simple quoting is accepted — an escape means we don't know the
527
+ * key for certain, so it reads as unknown. */
528
+ function readQuotedKey(source: string, start: number): string | null {
529
+ const quote = source[start];
530
+ if (quote !== '"' && quote !== "'") return null;
531
+ const close = source.indexOf(quote, start + 1);
532
+ if (close === -1) return null;
533
+ const raw = source.slice(start + 1, close);
534
+ return raw.includes('\\') ? null : raw;
535
+ }
536
+
537
+ /**
538
+ * An object entry's key: a bare identifier, a quoted key, or — as
539
+ * `export const collections = { blog, works }` writes it — a shorthand, where
540
+ * the entry *is* the name. Null when it is none of those.
541
+ */
542
+ function entryKey(source: string, code: string, e: Entry): string | null {
543
+ if (e.name === '') return readQuotedKey(source, e.start);
544
+ if (e.name) return e.name;
545
+ const m = IDENT.exec(code.slice(e.start, e.end));
546
+ return m && e.start + m[0].length === trimEnd(code, e.start, e.end) ? m[0] : null;
547
+ }
548
+
549
+ function locateRegistry(source: string, code: string): Located['registry'] {
550
+ const m = /(^|[\n;])[ \t]*export[ \t]+const[ \t]+collections[ \t]*(?::[^=;]*)?=[\s]*\{/.exec(code);
551
+ if (!m) return null;
552
+ const open = m.index + m[0].length - 1;
553
+ const close = matchBracket(code, open);
554
+ if (close === -1) return null;
555
+ return {
556
+ open,
557
+ close,
558
+ start: m.index + m[1].length,
559
+ entries: readEntries(code, open, close),
560
+ };
561
+ }
562
+
563
+ // --- Reading -----------------------------------------------------------------
564
+
565
+ /** Every `defineCollection` block in the file, in source order. */
566
+ export function readCollectionBlocks(source: string): CollectionBlock[] {
567
+ return locate(source).blocks.map((b) => ({
568
+ name: b.name,
569
+ schemaForm: b.schemaForm,
570
+ fields: b.fields,
571
+ ...(b.unrecognized ? { unrecognized: b.unrecognized } : {}),
572
+ registered: b.registered,
573
+ line: source.slice(0, b.blockStart).split('\n').length,
574
+ }));
575
+ }
576
+
577
+ // --- Rendering ---------------------------------------------------------------
578
+
579
+ export type RenderResult = { ok: true; expr: string } | { ok: false; error: string };
580
+
581
+ /** Base zod expression per {@link FieldType}. `json` and `image` are handled by
582
+ * {@link renderZodField} — the first can't be synthesized from a widget name,
583
+ * the second needs the function schema form. */
584
+ const BASE_EXPR: Partial<Record<FieldType, string>> = {
585
+ // `textarea` is widget-only: `terminalType` never produces it, so it stores a
586
+ // `fields.<key>.widget` override alongside a plain string in the schema.
587
+ text: 'z.string()',
588
+ textarea: 'z.string()',
589
+ date: 'z.coerce.date()',
590
+ number: 'z.number()',
591
+ boolean: 'z.boolean()',
592
+ tags: 'z.array(z.string())',
593
+ };
594
+
595
+ /**
596
+ * The zod expression for a field — the inverse of
597
+ * `schema-introspect.ts::terminalType`, and the piece those two must keep in
598
+ * step. `tests/content-config-patch.test.ts` round-trips every `FieldType`
599
+ * through the real zod to hold the pair together.
600
+ */
601
+ export function renderZodField(field: SchemaField, form: SchemaForm): RenderResult {
602
+ let base: string;
603
+ if (field.type === 'json') {
604
+ return {
605
+ ok: false,
606
+ error: 'A read-only JSON field has no schema shape to write. Edit the config by hand.',
607
+ };
608
+ } else if (field.type === 'image') {
609
+ if (form !== 'function') {
610
+ return {
611
+ ok: false,
612
+ error:
613
+ "An image() field needs Astro's image helper, which only the function schema form " +
614
+ 'receives. Change this collection\'s schema to `({ image }) => z.object({ … })` first.',
615
+ };
616
+ }
617
+ base = 'image()';
618
+ } else if (field.type === 'select') {
619
+ const options = field.options ?? [];
620
+ if (options.length === 0) {
621
+ return { ok: false, error: 'A select field needs at least one option.' };
622
+ }
623
+ base = `z.enum([${options.map(quote).join(', ')}])`;
624
+ } else {
625
+ const known = BASE_EXPR[field.type];
626
+ if (!known) return { ok: false, error: `Unsupported field type "${field.type}".` };
627
+ base = known;
628
+ }
629
+
630
+ const hasDefault = field.defaultValue !== undefined && field.defaultValue !== '';
631
+ if (hasDefault) {
632
+ if (field.type === 'date' || field.type === 'image') {
633
+ return {
634
+ ok: false,
635
+ error: `A ${field.type} field can't take a default from here — add one in the config by hand.`,
636
+ };
637
+ }
638
+ const lit = literal(field.defaultValue, field.type);
639
+ if (!lit) return { ok: false, error: `That default isn't valid for a ${field.type} field.` };
640
+ return { ok: true, expr: `${base}.default(${lit})` };
641
+ }
642
+ return { ok: true, expr: field.required ? base : `${base}.optional()` };
643
+ }
644
+
645
+ /** Single-quoted string literal, matching the repo's own style. */
646
+ function quote(text: string): string {
647
+ return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
648
+ }
649
+
650
+ /** A default value as source, or null when it can't be one. */
651
+ function literal(value: unknown, type: FieldType): string | null {
652
+ if (type === 'number') {
653
+ const n = typeof value === 'number' ? value : Number(value);
654
+ return Number.isFinite(n) ? String(n) : null;
655
+ }
656
+ if (type === 'boolean') {
657
+ if (typeof value === 'boolean') return String(value);
658
+ if (value === 'true' || value === 'false') return String(value);
659
+ return null;
660
+ }
661
+ if (type === 'tags') {
662
+ const list = Array.isArray(value) ? value : String(value).split(',').map((s) => s.trim());
663
+ if (!list.every((v) => typeof v === 'string')) return null;
664
+ return `[${list.filter(Boolean).map(quote).join(', ')}]`;
665
+ }
666
+ return typeof value === 'string' ? quote(value) : null;
667
+ }
668
+
669
+ // --- Patching ----------------------------------------------------------------
670
+
671
+ /** Resolve a collection to a patchable block, or the refusal that explains why
672
+ * it isn't one. */
673
+ function patchable(
674
+ source: string,
675
+ collection: string,
676
+ ): { ok: true; found: LocatedBlock } | { ok: false; error: string; code: ConfigRefusalCode } {
677
+ const { blocks } = locate(source);
678
+ const found = blocks.find((b) => b.name === collection);
679
+ if (!found) {
680
+ return {
681
+ ok: false,
682
+ code: 'missing',
683
+ error: `No \`const ${collection} = defineCollection(…)\` in this config.`,
684
+ };
685
+ }
686
+ if (found.unrecognized || found.fieldsOpen === -1) {
687
+ return {
688
+ ok: false,
689
+ code: 'unrecognized',
690
+ error: `${collection}: ${found.unrecognized ?? 'the schema field list could not be located'}.`,
691
+ };
692
+ }
693
+ return { ok: true, found };
694
+ }
695
+
696
+ /** Add a field to a collection's schema. */
697
+ export function addField(source: string, collection: string, field: SchemaField): ConfigPatchResult {
698
+ const target = patchable(source, collection);
699
+ if (!target.ok) return target;
700
+ const { found } = target;
701
+ if (!validName(field.name)) {
702
+ return { ok: false, code: 'unsupported', error: `"${field.name}" is not a valid field name.` };
703
+ }
704
+ if (found.fields.some((f) => f.name === field.name)) {
705
+ return { ok: false, code: 'exists', error: `${collection} already has a "${field.name}" field.` };
706
+ }
707
+ const rendered = renderZodField(field, found.schemaForm ?? 'object');
708
+ if (!rendered.ok) return { ok: false, code: 'unsupported', error: rendered.error };
709
+
710
+ return {
711
+ ok: true,
712
+ newSource: appendEntry(
713
+ source,
714
+ found.fieldsOpen,
715
+ found.fieldsClose,
716
+ found.entries,
717
+ `${field.name}: ${rendered.expr}`,
718
+ fieldIndent(source, found),
719
+ ),
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Append `text` as a new last entry of the object literal spanning
725
+ * `[open, close]`, normalizing the previous last entry's trailing comma and
726
+ * keeping the file's existing single-line or multiline style. `text` carries no
727
+ * comma of its own — one is added only where the style wants it.
728
+ */
729
+ function appendEntry(
730
+ source: string,
731
+ open: number,
732
+ close: number,
733
+ entries: Entry[],
734
+ text: string,
735
+ indent: string,
736
+ ): string {
737
+ const last = entries[entries.length - 1];
738
+ const lastEnd = last ? last.end : open + 1;
739
+ const between = source.slice(lastEnd, close);
740
+ const needsComma = last !== undefined && !blankNonCode(between).includes(',');
741
+
742
+ if (!source.slice(open, close).includes('\n')) {
743
+ // `z.object({ title: z.string() })` — stay on the one line, no trailing comma.
744
+ const at = trimEnd(source, open + 1, close);
745
+ return `${source.slice(0, at)}${needsComma ? ',' : ''} ${text} ${source.slice(close)}`;
746
+ }
747
+
748
+ let lineStart = close;
749
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--;
750
+ const ownLine = /^[ \t]*$/.test(source.slice(lineStart, close));
751
+ const insertAt = ownLine ? lineStart : close;
752
+ const insertion = ownLine ? `${indent}${text},\n` : `\n${indent}${text},`;
753
+ return (
754
+ source.slice(0, lastEnd) +
755
+ (needsComma ? ',' : '') +
756
+ source.slice(lastEnd, insertAt) +
757
+ insertion +
758
+ source.slice(insertAt)
759
+ );
760
+ }
761
+
762
+ /**
763
+ * Retype an existing field. Only the zod expression is replaced — the key, its
764
+ * comments and the surrounding formatting are untouched.
765
+ *
766
+ * There is deliberately no rename: a schema key is the frontmatter key in every
767
+ * entry file, so renaming it here alone would break the collection. The designer
768
+ * offers remove + add instead, which is the honest shape of that operation.
769
+ */
770
+ export function updateField(
771
+ source: string,
772
+ collection: string,
773
+ field: SchemaField,
774
+ ): ConfigPatchResult {
775
+ const target = patchable(source, collection);
776
+ if (!target.ok) return target;
777
+ const { found } = target;
778
+ const entry = found.entries.find((e) => e.name === field.name);
779
+ if (!entry) {
780
+ return { ok: false, code: 'missing', error: `${collection} has no "${field.name}" field.` };
781
+ }
782
+ const rendered = renderZodField(field, found.schemaForm ?? 'object');
783
+ if (!rendered.ok) return { ok: false, code: 'unsupported', error: rendered.error };
784
+ return {
785
+ ok: true,
786
+ newSource: source.slice(0, entry.valueStart) + rendered.expr + source.slice(entry.end),
787
+ };
788
+ }
789
+
790
+ /**
791
+ * Switch a collection's schema between the two forms.
792
+ *
793
+ * The forms differ by exactly one span — the arrow function's head — so this is
794
+ * an insert or a delete at a located offset, never a rewrite. Promoting leaves
795
+ * every existing field expression, comment and line break where it was; the
796
+ * `z.object({` that followed `schema:` simply now follows `({ image }) =>`.
797
+ *
798
+ * Demoting is refused while any field still calls `image()`, because that
799
+ * helper would go out of scope and the collection would stop building. The
800
+ * refusal names the fields, since "remove them first" is only actionable if you
801
+ * know which they are.
802
+ *
803
+ * Neither direction re-indents the field list. A demoted schema's fields keep
804
+ * the deeper indentation the function form gave them — cosmetic, visible in
805
+ * `git diff`, and preferable to moving lines this patch was not asked to touch.
806
+ */
807
+ export function setSchemaForm(
808
+ source: string,
809
+ collection: string,
810
+ form: SchemaForm,
811
+ ): ConfigPatchResult {
812
+ const target = patchable(source, collection);
813
+ if (!target.ok) return target;
814
+ const { found } = target;
815
+ if (found.schemaForm === form) {
816
+ return { ok: true, newSource: source };
817
+ }
818
+ if (form === 'function') {
819
+ return {
820
+ ok: true,
821
+ newSource:
822
+ source.slice(0, found.zodObjectAt) + '({ image }) => ' + source.slice(found.zodObjectAt),
823
+ };
824
+ }
825
+ const users = found.fields.filter((f) => usesImageHelper(f.expr)).map((f) => f.name);
826
+ if (users.length > 0) {
827
+ return {
828
+ ok: false,
829
+ code: 'unsupported',
830
+ error:
831
+ `${collection}: ${users.join(', ')} ${users.length === 1 ? 'uses' : 'use'} image(), ` +
832
+ 'which only the function schema form provides. Remove or retype ' +
833
+ `${users.length === 1 ? 'it' : 'them'} first.`,
834
+ };
835
+ }
836
+ return {
837
+ ok: true,
838
+ newSource: source.slice(0, found.schemaValueStart) + source.slice(found.zodObjectAt),
839
+ };
840
+ }
841
+
842
+ /** Whether a field expression calls Astro's `image()` helper. Read off the
843
+ * blanked copy so an `image()` inside a string or a comment doesn't count. */
844
+ function usesImageHelper(expr: string): boolean {
845
+ return /(^|[^\w$.])image\s*\(/.test(blankNonCode(expr));
846
+ }
847
+
848
+ /**
849
+ * Remove a field from a collection's schema.
850
+ *
851
+ * The field's own line goes; a comment on the line above stays. Deleting a
852
+ * comment we only *assume* belonged to the field would be a guess, and an
853
+ * orphaned comment is visible in `git diff` where a silently deleted one is not.
854
+ */
855
+ export function removeField(source: string, collection: string, name: string): ConfigPatchResult {
856
+ const target = patchable(source, collection);
857
+ if (!target.ok) return target;
858
+ const { found } = target;
859
+ const idx = found.entries.findIndex((e) => e.name === name);
860
+ if (idx === -1) {
861
+ return { ok: false, code: 'missing', error: `${collection} has no "${name}" field.` };
862
+ }
863
+ const entry = found.entries[idx];
864
+ const code = blankNonCode(source);
865
+
866
+ // Take the trailing comma with it, plus the rest of that line when nothing
867
+ // else shares it.
868
+ let cut = entry.end;
869
+ const afterComma = skipSpace(code, cut, found.fieldsClose);
870
+ if (code[afterComma] === ',') cut = afterComma + 1;
871
+ else if (idx > 0) {
872
+ // Last entry with no trailing comma: drop the previous one's comma instead.
873
+ const prev = found.entries[idx - 1];
874
+ const between = code.slice(prev.end, entry.start);
875
+ const commaAt = prev.end + between.indexOf(',');
876
+ if (between.includes(',')) return spliceOut(source, commaAt, cut);
877
+ }
878
+ let start = entry.start;
879
+ let lineStart = start;
880
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--;
881
+ if (/^[ \t]*$/.test(source.slice(lineStart, start))) start = lineStart;
882
+ let end = cut;
883
+ while (end < source.length && (source[end] === ' ' || source[end] === '\t')) end++;
884
+ if (source[end] === '\n' && start === lineStart) end++;
885
+ else if (source.startsWith('\r\n', end) && start === lineStart) end += 2;
886
+ return spliceOut(source, start, end);
887
+ }
888
+
889
+ function spliceOut(source: string, from: number, to: number): ConfigPatchResult {
890
+ return { ok: true, newSource: source.slice(0, from) + source.slice(to) };
891
+ }
892
+
893
+ /**
894
+ * Append a collection: its `defineCollection` block, its entry in
895
+ * `export const collections`, and — when the file doesn't import it yet — the
896
+ * `glob` loader import the block needs.
897
+ *
898
+ * Emitted with two-space indentation, the convention in Astro's own templates
899
+ * and in every config this designer writes into.
900
+ */
901
+ export function addCollection(source: string, spec: NewCollection): ConfigPatchResult {
902
+ if (!validName(spec.name)) {
903
+ return { ok: false, code: 'unsupported', error: `"${spec.name}" is not a valid collection name.` };
904
+ }
905
+ const { code, blocks, registry } = locate(source);
906
+ if (blocks.some((b) => b.name === spec.name)) {
907
+ return { ok: false, code: 'exists', error: `This config already defines "${spec.name}".` };
908
+ }
909
+ if (!registry) {
910
+ return {
911
+ ok: false,
912
+ code: 'missing',
913
+ error: 'No `export const collections = { … }` to register the collection in.',
914
+ };
915
+ }
916
+ if (registry.entries.some((e) => entryKey(source, code, e) === spec.name)) {
917
+ return { ok: false, code: 'exists', error: `"${spec.name}" is already registered.` };
918
+ }
919
+ for (const ident of ['defineCollection', 'z']) {
920
+ if (!importsIdentifier(code, ident)) {
921
+ return {
922
+ ok: false,
923
+ code: 'unrecognized',
924
+ error: `This config doesn't import \`${ident}\`, so a generated block wouldn't compile.`,
925
+ };
926
+ }
927
+ }
928
+
929
+ // The switch decides, except that an image field forces the function form —
930
+ // there is no valid config in which one is asked for and the other applies.
931
+ const form: SchemaForm =
932
+ spec.schemaForm === 'function' || spec.fields.some((f) => f.type === 'image')
933
+ ? 'function'
934
+ : 'object';
935
+ const lines: string[] = [];
936
+ for (const f of spec.fields) {
937
+ if (!validName(f.name)) {
938
+ return { ok: false, code: 'unsupported', error: `"${f.name}" is not a valid field name.` };
939
+ }
940
+ const rendered = renderZodField(f, form);
941
+ if (!rendered.ok) return { ok: false, code: 'unsupported', error: rendered.error };
942
+ lines.push(`${f.name}: ${rendered.expr},`);
943
+ }
944
+
945
+ const pattern = spec.pattern ?? '**/*.md';
946
+ const base = `./${spec.dir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')}`;
947
+ const indent = form === 'function' ? ' ' : ' ';
948
+ const objectLines = lines.map((l) => `${indent}${l}`).join('\n');
949
+ const schema =
950
+ form === 'function'
951
+ ? ` schema: ({ image }) =>\n z.object({\n${objectLines}\n }),`
952
+ : ` schema: z.object({\n${objectLines}\n }),`;
953
+ const block =
954
+ `const ${spec.name} = defineCollection({\n` +
955
+ ` loader: glob({ pattern: '${pattern}', base: '${base}' }),\n` +
956
+ `${schema}\n` +
957
+ `});\n\n`;
958
+
959
+ // Register the name first. The block goes in *before* the registry statement,
960
+ // so editing the registry first leaves every offset this function holds valid.
961
+ const regIndent =
962
+ /\n([ \t]*)\S/.exec(source.slice(registry.open, registry.close))?.[1] ?? ' ';
963
+ let out = appendEntry(
964
+ source,
965
+ registry.open,
966
+ registry.close,
967
+ registry.entries,
968
+ spec.name,
969
+ regIndent,
970
+ );
971
+ out = out.slice(0, registry.start) + block + out.slice(registry.start);
972
+
973
+ if (!importsIdentifier(code, 'glob')) {
974
+ out = addGlobImport(out);
975
+ }
976
+ return { ok: true, newSource: out };
977
+ }
978
+
979
+ /** Whether an identifier appears in an import statement's binding list. */
980
+ function importsIdentifier(code: string, ident: string): boolean {
981
+ const re = new RegExp(
982
+ `(^|[\\n;])[ \\t]*import[\\s\\S]*?\\b${ident}\\b[\\s\\S]*?from[ \\t]`,
983
+ 'm',
984
+ );
985
+ for (const stmt of importStatements(code)) {
986
+ if (re.test(stmt.text)) return true;
987
+ }
988
+ return false;
989
+ }
990
+
991
+ interface ImportStatement {
992
+ text: string;
993
+ end: number;
994
+ }
995
+
996
+ /** Top-level `import … from '…';` statements of a blanked source. The module
997
+ * specifier is blanked, so matching stops at `from`. */
998
+ function importStatements(code: string): ImportStatement[] {
999
+ const out: ImportStatement[] = [];
1000
+ const re = /(^|\n)[ \t]*import\b[^\n;]*;?/g;
1001
+ let m: RegExpExecArray | null;
1002
+ while ((m = re.exec(code))) {
1003
+ out.push({ text: m[0], end: m.index + m[0].length });
1004
+ }
1005
+ return out;
1006
+ }
1007
+
1008
+ /** Add `import { glob } from 'astro/loaders';` after the last import. */
1009
+ function addGlobImport(source: string): string {
1010
+ const imports = importStatements(blankNonCode(source));
1011
+ const line = "import { glob } from 'astro/loaders';";
1012
+ if (imports.length === 0) return `${line}\n${source}`;
1013
+ const at = imports[imports.length - 1].end;
1014
+ return `${source.slice(0, at)}\n${line}${source.slice(at)}`;
1015
+ }
1016
+
1017
+ const NAME_RE = /^[A-Za-z_$][\w$]*$/;
1018
+
1019
+ /** Identifier-safe, so it can be a bare object key and a `const` name without
1020
+ * quoting. The designer never needs anything else, and refusing keeps every
1021
+ * generated expression a shape this module can read back. */
1022
+ function validName(name: string): boolean {
1023
+ return typeof name === 'string' && NAME_RE.test(name);
1024
+ }
1025
+
1026
+ /** Indentation to give a new field: the last existing field's, else one step in
1027
+ * from the `{`. */
1028
+ function fieldIndent(source: string, block: LocatedBlock): string {
1029
+ const last = block.entries[block.entries.length - 1];
1030
+ const anchor = last ? last.start : block.fieldsOpen;
1031
+ let lineStart = anchor;
1032
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--;
1033
+ const lead = /^[ \t]*/.exec(source.slice(lineStart, anchor))?.[0] ?? '';
1034
+ return last ? lead : lead + ' ';
1035
+ }