@nuasite/cms 0.40.0 → 0.41.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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.40.0",
17
+ "version": "0.41.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
4
4
  import { getProjectRoot } from './config'
5
- import { parseContentConfig, type ParsedConfig } from './content-config-ast'
5
+ import { parseContentConfig, type ParsedConfig, type ParsedField } from './content-config-ast'
6
6
  import { slugifyHref } from './shared'
7
7
  import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
8
8
 
@@ -213,9 +213,14 @@ function inferFieldType(value: unknown, key: string): FieldType {
213
213
  }
214
214
 
215
215
  /**
216
- * Merge field observations from multiple files to determine final field definition
216
+ * Merge field observations from multiple files to determine final field definition.
217
+ * `depth` guards against pathological deeply-nested content blowing the stack —
218
+ * real-world YAML/JSON rarely exceeds 5 levels, so the cap is well above realistic use.
217
219
  */
218
- function mergeFieldObservations(observations: FieldObservation[]): FieldDefinition[] {
220
+ const MAX_NESTED_FIELD_DEPTH = 16
221
+
222
+ function mergeFieldObservations(observations: FieldObservation[], depth: number = 0): FieldDefinition[] {
223
+ if (depth >= MAX_NESTED_FIELD_DEPTH) return []
219
224
  const fields: FieldDefinition[] = []
220
225
 
221
226
  for (const obs of observations) {
@@ -283,12 +288,26 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
283
288
  for (const item of objectItems) {
284
289
  collectFieldObservations(subFieldMap, item, objectItems.length)
285
290
  }
286
- field.fields = mergeFieldObservations(Array.from(subFieldMap.values()))
291
+ field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
287
292
  }
288
293
  }
289
294
  }
290
295
  }
291
296
 
297
+ // For plain object values, recurse into sub-fields so the editor can render them.
298
+ if (fieldType === 'object') {
299
+ const objectValues = nonNullValues.filter(
300
+ (v): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v),
301
+ )
302
+ if (objectValues.length > 0) {
303
+ const subFieldMap = new Map<string, FieldObservation>()
304
+ for (const item of objectValues) {
305
+ collectFieldObservations(subFieldMap, item, objectValues.length)
306
+ }
307
+ field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
308
+ }
309
+ }
310
+
292
311
  fields.push(field)
293
312
  }
294
313
 
@@ -456,17 +475,65 @@ function applyParsedConfig(
456
475
  for (const pf of parsedColl.fields) {
457
476
  const field = fieldsByName.get(pf.name)
458
477
  if (!field) continue
459
- if (pf.type) {
460
- field.type = pf.type
461
- if (pf.options) field.options = pf.options
462
- }
463
- if (pf.hints) field.hints = pf.hints
464
- if (pf.astroImage) field.astroImage = true
465
- field.required = pf.required
478
+ applyParsedFieldOverrides(field, pf)
466
479
  }
467
480
  }
468
481
  }
469
482
 
483
+ /**
484
+ * Apply parsed schema overrides to an inferred field, recursing into nested object/array fields.
485
+ *
486
+ * Note on schema-vs-inferred merging at nested levels: schema-declared sub-fields replace
487
+ * the inferred list rather than merging. Inferred-only sub-fields are *not* lost — the
488
+ * editor's `ObjectFields` recovers them via its `extraKeys` calculation (field value keys
489
+ * minus schemaNames), routes them through `FrontmatterField` (value-based auto-detect),
490
+ * and offers a remove button. Merging here would defeat that.
491
+ */
492
+ function applyParsedFieldOverrides(field: FieldDefinition, pf: ParsedField): void {
493
+ if (pf.type) {
494
+ field.type = pf.type
495
+ if (pf.options) field.options = pf.options
496
+ }
497
+ if (pf.itemType) field.itemType = pf.itemType
498
+ if (pf.hints) field.hints = pf.hints
499
+ if (pf.astroImage) field.astroImage = true
500
+ field.required = pf.required
501
+
502
+ if (pf.fields) {
503
+ const existingByName = new Map((field.fields ?? []).map(f => [f.name, f]))
504
+ field.fields = pf.fields.map((subPf) => {
505
+ const existing = existingByName.get(subPf.name)
506
+ if (existing) {
507
+ applyParsedFieldOverrides(existing, subPf)
508
+ return existing
509
+ }
510
+ return parsedFieldToFieldDefinition(subPf)
511
+ })
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Build a FieldDefinition from a parsed schema field when no inferred counterpart exists.
517
+ * Falls back to `'text'` when the parser couldn't pin a type — keeps the field visible
518
+ * and editable. Schema-declared-but-data-absent fields would otherwise vanish.
519
+ */
520
+ function parsedFieldToFieldDefinition(pf: ParsedField): FieldDefinition {
521
+ const fd: FieldDefinition = {
522
+ name: pf.name,
523
+ // A parsed field with nested children but no explicit type is necessarily an object.
524
+ // Otherwise default to 'text' so users can still fill in schema-declared fields
525
+ // whose helper the parser didn't recognize.
526
+ type: pf.type ?? (pf.fields ? 'object' : 'text'),
527
+ required: pf.required,
528
+ }
529
+ if (pf.options) fd.options = pf.options
530
+ if (pf.itemType) fd.itemType = pf.itemType
531
+ if (pf.hints) fd.hints = pf.hints
532
+ if (pf.astroImage) fd.astroImage = true
533
+ if (pf.fields) fd.fields = pf.fields.map(parsedFieldToFieldDefinition)
534
+ return fd
535
+ }
536
+
470
537
  /** Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries. */
471
538
  function applyCollectionOrderBy(
472
539
  collections: Record<string, CollectionDefinition>,
@@ -20,6 +20,10 @@ export interface ParsedField {
20
20
  reference?: ParsedReference
21
21
  /** True when the field is `image()` from an Astro callback schema, which routes through `astro:assets`. */
22
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[]
23
27
  }
24
28
 
25
29
  export interface ParsedCollection {
@@ -33,6 +37,7 @@ const FIELD_HELPER_TYPES = new Set([
33
37
  'text',
34
38
  'number',
35
39
  'image',
40
+ 'file',
36
41
  'url',
37
42
  'email',
38
43
  'tel',
@@ -40,6 +45,8 @@ const FIELD_HELPER_TYPES = new Set([
40
45
  'date',
41
46
  'datetime',
42
47
  'time',
48
+ 'year',
49
+ 'month',
43
50
  'textarea',
44
51
  ])
45
52
 
@@ -56,6 +63,26 @@ const VALID_HINT_KEYS = new Set([
56
63
 
57
64
  const WRAPPER_METHODS = new Set(['optional', 'nullable', 'nullish', 'default'])
58
65
 
66
+ /** Map of top-level `const <name> = <expr>` bindings within a single config file. */
67
+ type Bindings = Map<string, t.Node>
68
+
69
+ /**
70
+ * Follow `Identifier` references through same-file `const` bindings until reaching
71
+ * a non-Identifier node. Cycle-safe via the visited set. Returns the original node
72
+ * unchanged when the identifier is unbound or already visited.
73
+ */
74
+ function resolveExpression(node: t.Node, bindings: Bindings, visited: Set<string> = new Set()): t.Node {
75
+ let current: t.Node = node
76
+ while (current.type === 'Identifier') {
77
+ if (visited.has(current.name)) return current
78
+ visited.add(current.name)
79
+ const next = bindings.get(current.name)
80
+ if (!next) return current
81
+ current = next
82
+ }
83
+ return current
84
+ }
85
+
59
86
  /** Cached parse result keyed by absolute path; invalidated by mtime. */
60
87
  const parseCache = new Map<string, { mtimeMs: number; parsed: ParsedConfig }>()
61
88
 
@@ -95,8 +122,11 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
95
122
  const ast = parseFrontmatter(source, sourcePath) as unknown as t.File | null
96
123
  if (!ast) return result
97
124
 
98
- // Collect `const X = defineCollection({...})` declarations and the
99
- // `export const collections = { name: X, ... }` mapping, in any order.
125
+ // Single pass: collect every top-level `const X = <expr>` binding (so we can
126
+ // later resolve Identifier references like `cs: TestimonialTranslation`),
127
+ // while also picking out `defineCollection({...})` calls and the
128
+ // `export const collections = { name: X, ... }` mapping.
129
+ const bindings: Bindings = new Map()
100
130
  const collectionDecls = new Map<string, t.ObjectExpression>()
101
131
  const exportMap = new Map<string, string>() // varName → collectionName
102
132
 
@@ -112,6 +142,8 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
112
142
  if (decl.id.type !== 'Identifier') continue
113
143
  if (!decl.init) continue
114
144
 
145
+ bindings.set(decl.id.name, decl.init)
146
+
115
147
  if (decl.id.name === 'collections' && decl.init.type === 'ObjectExpression') {
116
148
  for (const prop of decl.init.properties) {
117
149
  if (prop.type !== 'ObjectProperty') continue
@@ -144,12 +176,12 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
144
176
  ) as t.ObjectProperty | undefined
145
177
  if (!schemaProperty) continue
146
178
 
147
- const schemaObject = unwrapSchemaToObject(schemaProperty.value)
179
+ const schemaObject = unwrapSchemaToObject(schemaProperty.value, bindings)
148
180
  if (!schemaObject) continue
149
181
 
150
182
  result.set(collectionName, {
151
183
  name: collectionName,
152
- fields: parseSchemaFields(schemaObject),
184
+ fields: parseSchemaFields(schemaObject, bindings),
153
185
  })
154
186
  }
155
187
 
@@ -168,24 +200,27 @@ function propertyKeyName(key: t.Node): string | null {
168
200
 
169
201
  /**
170
202
  * Unwrap a `schema:` value down to the top-level (z|n).object({ ... }) ObjectExpression.
171
- * Handles direct calls and the Astro callback form `({ image }) => z.object({...})`.
203
+ * Handles direct calls, the Astro callback form `({ image }) => z.object({...})`,
204
+ * and same-file variable references like `schema: BlogSchema`.
172
205
  */
173
- function unwrapSchemaToObject(node: t.Node): t.ObjectExpression | null {
174
- if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') {
175
- const body = node.body
206
+ function unwrapSchemaToObject(node: t.Node, bindings: Bindings): t.ObjectExpression | null {
207
+ const resolved = resolveExpression(node, bindings)
208
+
209
+ if (resolved.type === 'ArrowFunctionExpression' || resolved.type === 'FunctionExpression') {
210
+ const body = resolved.body
176
211
  if (body.type === 'BlockStatement') {
177
212
  for (const stmt of body.body) {
178
213
  if (stmt.type === 'ReturnStatement' && stmt.argument) {
179
- return unwrapSchemaToObject(stmt.argument)
214
+ return unwrapSchemaToObject(stmt.argument, bindings)
180
215
  }
181
216
  }
182
217
  return null
183
218
  }
184
- return unwrapSchemaToObject(body)
219
+ return unwrapSchemaToObject(body, bindings)
185
220
  }
186
221
 
187
- if (node.type === 'CallExpression') {
188
- const callee = node.callee
222
+ if (resolved.type === 'CallExpression') {
223
+ const callee = resolved.callee
189
224
  if (
190
225
  callee.type === 'MemberExpression'
191
226
  && callee.object.type === 'Identifier'
@@ -193,15 +228,17 @@ function unwrapSchemaToObject(node: t.Node): t.ObjectExpression | null {
193
228
  && callee.property.type === 'Identifier'
194
229
  && callee.property.name === 'object'
195
230
  ) {
196
- const arg = node.arguments[0]
197
- if (arg?.type === 'ObjectExpression') return arg
231
+ const arg = resolved.arguments[0]
232
+ if (!arg) return null
233
+ const resolvedArg = resolveExpression(arg, bindings)
234
+ if (resolvedArg.type === 'ObjectExpression') return resolvedArg
198
235
  }
199
236
  }
200
237
 
201
238
  return null
202
239
  }
203
240
 
204
- function parseSchemaFields(schemaObject: t.ObjectExpression): ParsedField[] {
241
+ function parseSchemaFields(schemaObject: t.ObjectExpression, bindings: Bindings): ParsedField[] {
205
242
  const fields: ParsedField[] = []
206
243
  for (const prop of schemaObject.properties) {
207
244
  if (prop.type !== 'ObjectProperty') continue
@@ -209,7 +246,7 @@ function parseSchemaFields(schemaObject: t.ObjectExpression): ParsedField[] {
209
246
  if (!name) continue
210
247
 
211
248
  const field: ParsedField = { name, required: true }
212
- analyzeFieldExpression(prop.value, field)
249
+ analyzeFieldExpression(prop.value, field, bindings)
213
250
  fields.push(field)
214
251
  }
215
252
  return fields
@@ -219,14 +256,18 @@ function parseSchemaFields(schemaObject: t.ObjectExpression): ParsedField[] {
219
256
  * Walk a field's value expression. Each layer is either a wrapper method call
220
257
  * (`.optional()`, `.default()`, `.nullable()`, `.nullish()`, `.orderBy(...)`)
221
258
  * or the base call (`n.image()`, `image()`, `z.enum([...])`, `n.array(reference(...))`).
259
+ *
260
+ * Resolves same-file `Identifier` references against `bindings` at each layer so
261
+ * patterns like `cs: TestimonialTranslation` and `en: TestimonialTranslation.optional()`
262
+ * are followed back to their defining call.
222
263
  */
223
- function analyzeFieldExpression(node: t.Node, field: ParsedField): void {
224
- let current: t.Node | null = node
264
+ function analyzeFieldExpression(node: t.Node, field: ParsedField, bindings: Bindings): void {
265
+ let current: t.Node | null = resolveExpression(node, bindings)
225
266
  while (current) {
226
267
  if (current.type !== 'CallExpression') return
227
268
 
228
269
  if (isBaseCall(current)) {
229
- analyzeBaseCall(current, field)
270
+ analyzeBaseCall(current, field, bindings)
230
271
  return
231
272
  }
232
273
 
@@ -242,7 +283,7 @@ function analyzeFieldExpression(node: t.Node, field: ParsedField): void {
242
283
  field.orderBy = { direction }
243
284
  }
244
285
 
245
- current = current.callee.object
286
+ current = resolveExpression(current.callee.object, bindings)
246
287
  }
247
288
  }
248
289
 
@@ -262,7 +303,7 @@ function isBaseCall(node: t.CallExpression): boolean {
262
303
  return false
263
304
  }
264
305
 
265
- function analyzeBaseCall(node: t.CallExpression, field: ParsedField): void {
306
+ function analyzeBaseCall(node: t.CallExpression, field: ParsedField, bindings: Bindings): void {
266
307
  const callee = node.callee
267
308
 
268
309
  // Bare image() / reference() from the schema callback form
@@ -314,11 +355,26 @@ function analyzeBaseCall(node: t.CallExpression, field: ParsedField): void {
314
355
  return
315
356
  }
316
357
 
317
- // (z|n).array(reference('foo'))array of references
358
+ // (z|n).object({...}) → nested object field
359
+ if ((ns === 'z' || ns === 'n') && fn === 'object') {
360
+ const arg = node.arguments[0]
361
+ if (!arg) return
362
+ const resolved = resolveExpression(arg, bindings)
363
+ if (resolved.type === 'ObjectExpression') {
364
+ field.type = 'object'
365
+ field.fields = parseSchemaFields(resolved, bindings)
366
+ }
367
+ return
368
+ }
369
+
370
+ // (z|n).array(<inner>) → array; inspect the element type
318
371
  if ((ns === 'z' || ns === 'n') && fn === 'array') {
319
- const inner = node.arguments[0]
372
+ const innerRaw = node.arguments[0]
373
+ if (!innerRaw) return
374
+ const inner = resolveExpression(innerRaw, bindings)
375
+ // Array of references: keep the existing flat shape so detectReferenceFields can wire it up.
320
376
  if (
321
- inner?.type === 'CallExpression'
377
+ inner.type === 'CallExpression'
322
378
  && inner.callee.type === 'Identifier'
323
379
  && inner.callee.name === 'reference'
324
380
  ) {
@@ -326,7 +382,18 @@ function analyzeBaseCall(node: t.CallExpression, field: ParsedField): void {
326
382
  if (target?.type === 'StringLiteral') {
327
383
  field.reference = { target: target.value, isArray: true }
328
384
  }
385
+ return
329
386
  }
387
+ // Array of anything else: analyze the inner expression and lift its type/fields.
388
+ // Note: nested arrays (`n.array(n.array(...))`) collapse here — `itemType` records
389
+ // only the outer element type, the inner element shape is lost. No editor flow
390
+ // currently renders nested arrays, so we don't carry a recursive `itemDefinition`
391
+ // yet; add one when editor support arrives.
392
+ const innerField: ParsedField = { name: '__item__', required: true }
393
+ analyzeFieldExpression(inner, innerField, bindings)
394
+ field.type = 'array'
395
+ if (innerField.type) field.itemType = innerField.type
396
+ if (innerField.fields) field.fields = innerField.fields
330
397
  return
331
398
  }
332
399
  }