@nuasite/cms 0.46.5 → 0.47.1

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.
@@ -1,501 +0,0 @@
1
- import type * as t from '@babel/types'
2
- import fs from 'node:fs/promises'
3
- import path from 'node:path'
4
- import { getProjectRoot } from './config'
5
- import { parseFrontmatter } from './source-finder/ast-parser'
6
- import type { FieldHints, FieldType } from './types'
7
-
8
- export interface ParsedReference {
9
- target: string
10
- isArray: boolean
11
- }
12
-
13
- export interface ParsedField {
14
- name: string
15
- type?: FieldType
16
- options?: string[]
17
- hints?: FieldHints
18
- required: boolean
19
- orderBy?: { direction: 'asc' | 'desc' }
20
- reference?: ParsedReference
21
- /** True when the field is `image()` from an Astro callback schema, which routes through `astro:assets`. */
22
- astroImage?: boolean
23
- /** Element type for `array` fields */
24
- itemType?: FieldType
25
- /** Nested fields for `object` fields, or per-item fields for `array` of objects */
26
- fields?: ParsedField[]
27
- }
28
-
29
- export interface ParsedCollection {
30
- name: string
31
- fields: ParsedField[]
32
- loaderPattern?: string
33
- loaderBase?: string
34
- }
35
-
36
- export type ParsedConfig = Map<string, ParsedCollection>
37
-
38
- const FIELD_HELPER_TYPES = new Set([
39
- 'text',
40
- 'number',
41
- 'image',
42
- 'file',
43
- 'url',
44
- 'email',
45
- 'tel',
46
- 'color',
47
- 'date',
48
- 'datetime',
49
- 'time',
50
- 'year',
51
- 'month',
52
- 'textarea',
53
- ])
54
-
55
- const VALID_HINT_KEYS = new Set([
56
- 'min',
57
- 'max',
58
- 'step',
59
- 'placeholder',
60
- 'maxLength',
61
- 'minLength',
62
- 'rows',
63
- 'accept',
64
- ])
65
-
66
- const WRAPPER_METHODS = new Set(['optional', 'nullable', 'nullish', 'default'])
67
-
68
- /** Map of top-level `const <name> = <expr>` bindings within a single config file. */
69
- type Bindings = Map<string, t.Node>
70
-
71
- /**
72
- * Follow `Identifier` references through same-file `const` bindings until reaching
73
- * a non-Identifier node. Cycle-safe via the visited set. Returns the original node
74
- * unchanged when the identifier is unbound or already visited.
75
- */
76
- function resolveExpression(node: t.Node, bindings: Bindings, visited: Set<string> = new Set()): t.Node {
77
- let current: t.Node = node
78
- while (current.type === 'Identifier') {
79
- if (visited.has(current.name)) return current
80
- visited.add(current.name)
81
- const next = bindings.get(current.name)
82
- if (!next) return current
83
- current = next
84
- }
85
- return current
86
- }
87
-
88
- /** Cached parse result keyed by absolute path; invalidated by mtime. */
89
- const parseCache = new Map<string, { mtimeMs: number; parsed: ParsedConfig }>()
90
-
91
- /**
92
- * Parse the project's Astro content config file (TypeScript) into a structured
93
- * representation of each collection's schema. Returns an empty map if no config
94
- * file exists or parsing fails.
95
- */
96
- export async function parseContentConfig(): Promise<ParsedConfig> {
97
- const projectRoot = getProjectRoot()
98
- for (const configPath of ['src/content/config.ts', 'src/content.config.ts']) {
99
- const fullPath = path.join(projectRoot, configPath)
100
- let stat: Awaited<ReturnType<typeof fs.stat>>
101
- try {
102
- stat = await fs.stat(fullPath)
103
- } catch {
104
- continue
105
- }
106
-
107
- const cached = parseCache.get(fullPath)
108
- if (cached && cached.mtimeMs === stat.mtimeMs) {
109
- if (cached.parsed.size > 0) return cached.parsed
110
- continue
111
- }
112
-
113
- const content = await fs.readFile(fullPath, 'utf-8')
114
- const parsed = parseConfigSource(content, configPath)
115
- parseCache.set(fullPath, { mtimeMs: stat.mtimeMs, parsed })
116
- if (parsed.size > 0) return parsed
117
- }
118
- return new Map()
119
- }
120
-
121
- /** Exported for unit testing — operates on a source string directly. */
122
- export function parseConfigSource(source: string, sourcePath?: string): ParsedConfig {
123
- const result: ParsedConfig = new Map()
124
- const ast = parseFrontmatter(source, sourcePath) as unknown as t.File | null
125
- if (!ast) return result
126
-
127
- // Single pass: collect every top-level `const X = <expr>` binding (so we can
128
- // later resolve Identifier references like `cs: TestimonialTranslation`),
129
- // while also picking out `defineCollection({...})` calls and the
130
- // `export const collections = { name: X, ... }` mapping.
131
- const bindings: Bindings = new Map()
132
- const collectionDecls = new Map<string, t.ObjectExpression>()
133
- const exportMap = new Map<string, string>() // varName → collectionName
134
- const inlineCollections = new Map<string, t.ObjectExpression>() // collectionName → defineCollection arg (inline form)
135
-
136
- for (const stmt of ast.program.body) {
137
- const varDecl = stmt.type === 'ExportNamedDeclaration' && stmt.declaration?.type === 'VariableDeclaration'
138
- ? stmt.declaration
139
- : stmt.type === 'VariableDeclaration'
140
- ? stmt
141
- : null
142
- if (!varDecl) continue
143
-
144
- for (const decl of varDecl.declarations) {
145
- if (decl.id.type !== 'Identifier') continue
146
- if (!decl.init) continue
147
-
148
- bindings.set(decl.id.name, decl.init)
149
-
150
- if (decl.id.name === 'collections' && decl.init.type === 'ObjectExpression') {
151
- for (const prop of decl.init.properties) {
152
- if (prop.type !== 'ObjectProperty') continue
153
- const key = propertyKeyName(prop.key)
154
- if (!key) continue
155
- if (prop.value.type === 'Identifier') {
156
- exportMap.set(prop.value.name, key)
157
- } else if (prop.value.type === 'CallExpression' && isDefineCollectionCallee(prop.value.callee)) {
158
- // Inline form: `collections = { name: defineCollection({...}) }`
159
- const inlineArg = prop.value.arguments[0]
160
- if (inlineArg?.type === 'ObjectExpression') {
161
- inlineCollections.set(key, inlineArg)
162
- }
163
- }
164
- }
165
- continue
166
- }
167
-
168
- if (decl.init.type === 'CallExpression' && isDefineCollectionCallee(decl.init.callee)) {
169
- const arg = decl.init.arguments[0]
170
- if (arg?.type === 'ObjectExpression') {
171
- collectionDecls.set(decl.id.name, arg)
172
- }
173
- }
174
- }
175
- }
176
-
177
- // Unify both styles: inline `name: defineCollection({...})` and the
178
- // `const x = defineCollection({...}); collections = { name: x }` reference form.
179
- const collectionObjects = new Map<string, t.ObjectExpression>(inlineCollections)
180
- for (const [varName, collectionName] of exportMap) {
181
- const decl = collectionDecls.get(varName)
182
- if (decl) collectionObjects.set(collectionName, decl)
183
- }
184
-
185
- for (const [collectionName, decl] of collectionObjects) {
186
- const loaderProperty = decl.properties.find(
187
- p =>
188
- p.type === 'ObjectProperty'
189
- && propertyKeyName(p.key) === 'loader',
190
- ) as t.ObjectProperty | undefined
191
- const loaderOptions = loaderProperty ? extractGlobLoaderOptions(loaderProperty.value, bindings) : {}
192
- const loaderPattern = loaderOptions.pattern
193
- const loaderBase = loaderOptions.base
194
-
195
- const schemaProperty = decl.properties.find(
196
- p =>
197
- p.type === 'ObjectProperty'
198
- && propertyKeyName(p.key) === 'schema',
199
- ) as t.ObjectProperty | undefined
200
- if (!schemaProperty) {
201
- if (!loaderPattern) continue
202
- result.set(collectionName, {
203
- name: collectionName,
204
- fields: [],
205
- loaderPattern,
206
- loaderBase,
207
- })
208
- continue
209
- }
210
-
211
- const schemaObject = unwrapSchemaToObject(schemaProperty.value, bindings)
212
- if (!schemaObject) {
213
- if (!loaderPattern) continue
214
- result.set(collectionName, {
215
- name: collectionName,
216
- fields: [],
217
- loaderPattern,
218
- loaderBase,
219
- })
220
- continue
221
- }
222
-
223
- result.set(collectionName, {
224
- name: collectionName,
225
- fields: parseSchemaFields(schemaObject, bindings),
226
- loaderPattern,
227
- loaderBase,
228
- })
229
- }
230
-
231
- return result
232
- }
233
-
234
- function isDefineCollectionCallee(callee: t.Node): boolean {
235
- return callee.type === 'Identifier' && callee.name === 'defineCollection'
236
- }
237
-
238
- function propertyKeyName(key: t.Node): string | null {
239
- if (key.type === 'Identifier') return key.name
240
- if (key.type === 'StringLiteral') return key.value
241
- return null
242
- }
243
-
244
- function extractGlobLoaderOptions(node: t.Node, bindings: Bindings): { pattern?: string; base?: string } {
245
- const resolved = resolveExpression(node, bindings)
246
- if (resolved.type !== 'CallExpression') return {}
247
- if (!isGlobCallee(resolved.callee)) return {}
248
-
249
- const arg = resolved.arguments[0]
250
- if (!arg) return {}
251
- const options = resolveExpression(arg, bindings)
252
- if (options.type !== 'ObjectExpression') return {}
253
-
254
- const result: { pattern?: string; base?: string } = {}
255
- for (const prop of options.properties) {
256
- if (prop.type !== 'ObjectProperty') continue
257
- const key = propertyKeyName(prop.key)
258
- if (key !== 'pattern' && key !== 'base') continue
259
- const value = extractStaticString(prop.value, bindings)
260
- if (value !== undefined) result[key] = value
261
- }
262
-
263
- return result
264
- }
265
-
266
- function extractStaticString(node: t.Node, bindings: Bindings): string | undefined {
267
- const resolved = resolveExpression(node, bindings)
268
- if (resolved.type === 'StringLiteral') return resolved.value
269
- if (resolved.type === 'TemplateLiteral' && resolved.expressions.length === 0) {
270
- return resolved.quasis[0]?.value.cooked ?? resolved.quasis[0]?.value.raw
271
- }
272
- return undefined
273
- }
274
-
275
- function isGlobCallee(callee: t.Node): boolean {
276
- return callee.type === 'Identifier' && callee.name === 'glob'
277
- }
278
-
279
- /**
280
- * Unwrap a `schema:` value down to the top-level (z|n).object({ ... }) ObjectExpression.
281
- * Handles direct calls, the Astro callback form `({ image }) => z.object({...})`,
282
- * and same-file variable references like `schema: BlogSchema`.
283
- */
284
- function unwrapSchemaToObject(node: t.Node, bindings: Bindings): t.ObjectExpression | null {
285
- const resolved = resolveExpression(node, bindings)
286
-
287
- if (resolved.type === 'ArrowFunctionExpression' || resolved.type === 'FunctionExpression') {
288
- const body = resolved.body
289
- if (body.type === 'BlockStatement') {
290
- for (const stmt of body.body) {
291
- if (stmt.type === 'ReturnStatement' && stmt.argument) {
292
- return unwrapSchemaToObject(stmt.argument, bindings)
293
- }
294
- }
295
- return null
296
- }
297
- return unwrapSchemaToObject(body, bindings)
298
- }
299
-
300
- if (resolved.type === 'CallExpression') {
301
- const callee = resolved.callee
302
- if (
303
- callee.type === 'MemberExpression'
304
- && callee.object.type === 'Identifier'
305
- && (callee.object.name === 'z' || callee.object.name === 'n')
306
- && callee.property.type === 'Identifier'
307
- && callee.property.name === 'object'
308
- ) {
309
- const arg = resolved.arguments[0]
310
- if (!arg) return null
311
- const resolvedArg = resolveExpression(arg, bindings)
312
- if (resolvedArg.type === 'ObjectExpression') return resolvedArg
313
- }
314
- }
315
-
316
- return null
317
- }
318
-
319
- function parseSchemaFields(schemaObject: t.ObjectExpression, bindings: Bindings): ParsedField[] {
320
- const fields: ParsedField[] = []
321
- for (const prop of schemaObject.properties) {
322
- if (prop.type !== 'ObjectProperty') continue
323
- const name = propertyKeyName(prop.key)
324
- if (!name) continue
325
-
326
- const field: ParsedField = { name, required: true }
327
- analyzeFieldExpression(prop.value, field, bindings)
328
- fields.push(field)
329
- }
330
- return fields
331
- }
332
-
333
- /**
334
- * Walk a field's value expression. Each layer is either a wrapper method call
335
- * (`.optional()`, `.default()`, `.nullable()`, `.nullish()`, `.orderBy(...)`)
336
- * or the base call (`n.image()`, `image()`, `z.enum([...])`, `n.array(reference(...))`).
337
- *
338
- * Resolves same-file `Identifier` references against `bindings` at each layer so
339
- * patterns like `cs: TestimonialTranslation` and `en: TestimonialTranslation.optional()`
340
- * are followed back to their defining call.
341
- */
342
- function analyzeFieldExpression(node: t.Node, field: ParsedField, bindings: Bindings): void {
343
- let current: t.Node | null = resolveExpression(node, bindings)
344
- while (current) {
345
- if (current.type !== 'CallExpression') return
346
-
347
- if (isBaseCall(current)) {
348
- analyzeBaseCall(current, field, bindings)
349
- return
350
- }
351
-
352
- if (current.callee.type !== 'MemberExpression') return
353
- const property = current.callee.property
354
- const methodName = property.type === 'Identifier' ? property.name : ''
355
-
356
- if (WRAPPER_METHODS.has(methodName)) {
357
- field.required = false
358
- } else if (methodName === 'orderBy') {
359
- const arg = current.arguments[0]
360
- const direction = arg?.type === 'StringLiteral' && arg.value === 'desc' ? 'desc' : 'asc'
361
- field.orderBy = { direction }
362
- }
363
-
364
- current = resolveExpression(current.callee.object, bindings)
365
- }
366
- }
367
-
368
- /**
369
- * A "base call" is the innermost call that defines the field's type: a Zod/n
370
- * helper invocation or a bare `image()` / `reference()` from a callback param.
371
- */
372
- function isBaseCall(node: t.CallExpression): boolean {
373
- const callee = node.callee
374
- if (callee.type === 'Identifier') {
375
- return callee.name === 'image' || callee.name === 'reference'
376
- }
377
- if (callee.type === 'MemberExpression') {
378
- return callee.object.type === 'Identifier'
379
- && (callee.object.name === 'n' || callee.object.name === 'z')
380
- }
381
- return false
382
- }
383
-
384
- function analyzeBaseCall(node: t.CallExpression, field: ParsedField, bindings: Bindings): void {
385
- const callee = node.callee
386
-
387
- // Bare image() / reference() from the schema callback form
388
- if (callee.type === 'Identifier') {
389
- if (callee.name === 'image') {
390
- field.type = 'image'
391
- field.astroImage = true
392
- return
393
- }
394
- if (callee.name === 'reference') {
395
- const arg = node.arguments[0]
396
- if (arg?.type === 'StringLiteral') {
397
- field.reference = { target: arg.value, isArray: false }
398
- }
399
- return
400
- }
401
- return
402
- }
403
-
404
- if (callee.type !== 'MemberExpression') return
405
- if (callee.object.type !== 'Identifier' || callee.property.type !== 'Identifier') return
406
- const ns = callee.object.name
407
- const fn = callee.property.name
408
-
409
- // n.image(), n.url(), n.text(...), etc. — semantic field types from @nuasite/cms
410
- if (ns === 'n' && FIELD_HELPER_TYPES.has(fn)) {
411
- field.type = fn as FieldType
412
- const firstArg = node.arguments[0]
413
- if (firstArg?.type === 'ObjectExpression') {
414
- const hints = parseHintsFromObject(firstArg)
415
- if (hints) field.hints = hints
416
- }
417
- return
418
- }
419
-
420
- // (z|n).enum([...]) → select with options
421
- if ((ns === 'z' || ns === 'n') && fn === 'enum') {
422
- const arg = node.arguments[0]
423
- if (arg?.type === 'ArrayExpression') {
424
- const options: string[] = []
425
- for (const el of arg.elements) {
426
- if (el?.type === 'StringLiteral') options.push(el.value)
427
- }
428
- if (options.length > 0) {
429
- field.type = 'select'
430
- field.options = options
431
- }
432
- }
433
- return
434
- }
435
-
436
- // (z|n).object({...}) → nested object field
437
- if ((ns === 'z' || ns === 'n') && fn === 'object') {
438
- const arg = node.arguments[0]
439
- if (!arg) return
440
- const resolved = resolveExpression(arg, bindings)
441
- if (resolved.type === 'ObjectExpression') {
442
- field.type = 'object'
443
- field.fields = parseSchemaFields(resolved, bindings)
444
- }
445
- return
446
- }
447
-
448
- // (z|n).array(<inner>) → array; inspect the element type
449
- if ((ns === 'z' || ns === 'n') && fn === 'array') {
450
- const innerRaw = node.arguments[0]
451
- if (!innerRaw) return
452
- const inner = resolveExpression(innerRaw, bindings)
453
- // Array of references: keep the existing flat shape so detectReferenceFields can wire it up.
454
- if (
455
- inner.type === 'CallExpression'
456
- && inner.callee.type === 'Identifier'
457
- && inner.callee.name === 'reference'
458
- ) {
459
- const target = inner.arguments[0]
460
- if (target?.type === 'StringLiteral') {
461
- field.reference = { target: target.value, isArray: true }
462
- }
463
- return
464
- }
465
- // Array of anything else: analyze the inner expression and lift its type/fields.
466
- // Note: nested arrays (`n.array(n.array(...))`) collapse here — `itemType` records
467
- // only the outer element type, the inner element shape is lost. No editor flow
468
- // currently renders nested arrays, so we don't carry a recursive `itemDefinition`
469
- // yet; add one when editor support arrives.
470
- const innerField: ParsedField = { name: '__item__', required: true }
471
- analyzeFieldExpression(inner, innerField, bindings)
472
- field.type = 'array'
473
- if (innerField.type) field.itemType = innerField.type
474
- if (innerField.fields) field.fields = innerField.fields
475
- return
476
- }
477
- }
478
-
479
- function parseHintsFromObject(obj: t.ObjectExpression): FieldHints | undefined {
480
- const raw: Record<string, string | number> = {}
481
- for (const prop of obj.properties) {
482
- if (prop.type !== 'ObjectProperty') continue
483
- const key = propertyKeyName(prop.key)
484
- if (!key || !VALID_HINT_KEYS.has(key)) continue
485
-
486
- const value = prop.value
487
- if (value.type === 'NumericLiteral') {
488
- raw[key] = value.value
489
- } else if (
490
- value.type === 'UnaryExpression'
491
- && value.operator === '-'
492
- && value.argument.type === 'NumericLiteral'
493
- ) {
494
- raw[key] = -value.argument.value
495
- } else if (value.type === 'StringLiteral') {
496
- raw[key] = value.value
497
- }
498
- }
499
- if (Object.keys(raw).length === 0) return undefined
500
- return raw as FieldHints
501
- }