@nuasite/cms 0.46.4 → 0.47.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/dist/editor.js +2 -2
- package/package.json +7 -7
- package/src/admin/tsconfig.tsbuildinfo +1 -1
- package/src/build-processor.ts +1 -1
- package/src/dev-middleware.ts +29 -19
- package/src/handlers/api-routes.ts +1 -1
- package/src/index.ts +4 -4
- package/src/manifest-writer.ts +14 -6
- package/src/migrate-astro-image.ts +2 -2
- package/src/scan-cache.ts +22 -0
- package/src/source-finder/collection-finder.ts +76 -0
- package/src/types.ts +32 -215
- package/src/collection-scanner.ts +0 -990
- package/src/content-config-ast.ts +0 -501
|
@@ -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
|
-
}
|