@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/dist/editor.js +7728 -7482
- package/package.json +1 -1
- package/src/collection-scanner.ts +78 -11
- package/src/content-config-ast.ts +91 -24
- package/src/editor/components/fields.tsx +313 -52
- package/src/editor/components/frontmatter-fields.tsx +54 -2
- package/src/editor/components/frontmatter-sidebar.tsx +1 -0
- package/src/field-types.ts +15 -0
- package/src/types.ts +3 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
99
|
-
//
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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 (
|
|
188
|
-
const 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 =
|
|
197
|
-
if (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).
|
|
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
|
|
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
|
|
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
|
}
|