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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- 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
|
+
}
|