@nuasite/cms 0.40.0 → 0.42.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.
@@ -20,11 +20,17 @@ 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 {
26
30
  name: string
27
31
  fields: ParsedField[]
32
+ loaderPattern?: string
33
+ loaderBase?: string
28
34
  }
29
35
 
30
36
  export type ParsedConfig = Map<string, ParsedCollection>
@@ -33,6 +39,7 @@ const FIELD_HELPER_TYPES = new Set([
33
39
  'text',
34
40
  'number',
35
41
  'image',
42
+ 'file',
36
43
  'url',
37
44
  'email',
38
45
  'tel',
@@ -40,6 +47,8 @@ const FIELD_HELPER_TYPES = new Set([
40
47
  'date',
41
48
  'datetime',
42
49
  'time',
50
+ 'year',
51
+ 'month',
43
52
  'textarea',
44
53
  ])
45
54
 
@@ -56,6 +65,26 @@ const VALID_HINT_KEYS = new Set([
56
65
 
57
66
  const WRAPPER_METHODS = new Set(['optional', 'nullable', 'nullish', 'default'])
58
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
+
59
88
  /** Cached parse result keyed by absolute path; invalidated by mtime. */
60
89
  const parseCache = new Map<string, { mtimeMs: number; parsed: ParsedConfig }>()
61
90
 
@@ -95,10 +124,14 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
95
124
  const ast = parseFrontmatter(source, sourcePath) as unknown as t.File | null
96
125
  if (!ast) return result
97
126
 
98
- // Collect `const X = defineCollection({...})` declarations and the
99
- // `export const collections = { name: X, ... }` mapping, in any order.
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()
100
132
  const collectionDecls = new Map<string, t.ObjectExpression>()
101
133
  const exportMap = new Map<string, string>() // varName → collectionName
134
+ const inlineCollections = new Map<string, t.ObjectExpression>() // collectionName → defineCollection arg (inline form)
102
135
 
103
136
  for (const stmt of ast.program.body) {
104
137
  const varDecl = stmt.type === 'ExportNamedDeclaration' && stmt.declaration?.type === 'VariableDeclaration'
@@ -112,6 +145,8 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
112
145
  if (decl.id.type !== 'Identifier') continue
113
146
  if (!decl.init) continue
114
147
 
148
+ bindings.set(decl.id.name, decl.init)
149
+
115
150
  if (decl.id.name === 'collections' && decl.init.type === 'ObjectExpression') {
116
151
  for (const prop of decl.init.properties) {
117
152
  if (prop.type !== 'ObjectProperty') continue
@@ -119,6 +154,12 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
119
154
  if (!key) continue
120
155
  if (prop.value.type === 'Identifier') {
121
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
+ }
122
163
  }
123
164
  }
124
165
  continue
@@ -133,23 +174,57 @@ export function parseConfigSource(source: string, sourcePath?: string): ParsedCo
133
174
  }
134
175
  }
135
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)
136
180
  for (const [varName, collectionName] of exportMap) {
137
181
  const decl = collectionDecls.get(varName)
138
- if (!decl) continue
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
139
194
 
140
195
  const schemaProperty = decl.properties.find(
141
196
  p =>
142
197
  p.type === 'ObjectProperty'
143
198
  && propertyKeyName(p.key) === 'schema',
144
199
  ) as t.ObjectProperty | undefined
145
- if (!schemaProperty) continue
200
+ if (!schemaProperty) {
201
+ if (!loaderPattern) continue
202
+ result.set(collectionName, {
203
+ name: collectionName,
204
+ fields: [],
205
+ loaderPattern,
206
+ loaderBase,
207
+ })
208
+ continue
209
+ }
146
210
 
147
- const schemaObject = unwrapSchemaToObject(schemaProperty.value)
148
- if (!schemaObject) continue
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
+ }
149
222
 
150
223
  result.set(collectionName, {
151
224
  name: collectionName,
152
- fields: parseSchemaFields(schemaObject),
225
+ fields: parseSchemaFields(schemaObject, bindings),
226
+ loaderPattern,
227
+ loaderBase,
153
228
  })
154
229
  }
155
230
 
@@ -166,26 +241,64 @@ function propertyKeyName(key: t.Node): string | null {
166
241
  return null
167
242
  }
168
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
+
169
279
  /**
170
280
  * 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({...})`.
281
+ * Handles direct calls, the Astro callback form `({ image }) => z.object({...})`,
282
+ * and same-file variable references like `schema: BlogSchema`.
172
283
  */
173
- function unwrapSchemaToObject(node: t.Node): t.ObjectExpression | null {
174
- if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') {
175
- const body = node.body
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
176
289
  if (body.type === 'BlockStatement') {
177
290
  for (const stmt of body.body) {
178
291
  if (stmt.type === 'ReturnStatement' && stmt.argument) {
179
- return unwrapSchemaToObject(stmt.argument)
292
+ return unwrapSchemaToObject(stmt.argument, bindings)
180
293
  }
181
294
  }
182
295
  return null
183
296
  }
184
- return unwrapSchemaToObject(body)
297
+ return unwrapSchemaToObject(body, bindings)
185
298
  }
186
299
 
187
- if (node.type === 'CallExpression') {
188
- const callee = node.callee
300
+ if (resolved.type === 'CallExpression') {
301
+ const callee = resolved.callee
189
302
  if (
190
303
  callee.type === 'MemberExpression'
191
304
  && callee.object.type === 'Identifier'
@@ -193,15 +306,17 @@ function unwrapSchemaToObject(node: t.Node): t.ObjectExpression | null {
193
306
  && callee.property.type === 'Identifier'
194
307
  && callee.property.name === 'object'
195
308
  ) {
196
- const arg = node.arguments[0]
197
- if (arg?.type === 'ObjectExpression') return arg
309
+ const arg = resolved.arguments[0]
310
+ if (!arg) return null
311
+ const resolvedArg = resolveExpression(arg, bindings)
312
+ if (resolvedArg.type === 'ObjectExpression') return resolvedArg
198
313
  }
199
314
  }
200
315
 
201
316
  return null
202
317
  }
203
318
 
204
- function parseSchemaFields(schemaObject: t.ObjectExpression): ParsedField[] {
319
+ function parseSchemaFields(schemaObject: t.ObjectExpression, bindings: Bindings): ParsedField[] {
205
320
  const fields: ParsedField[] = []
206
321
  for (const prop of schemaObject.properties) {
207
322
  if (prop.type !== 'ObjectProperty') continue
@@ -209,7 +324,7 @@ function parseSchemaFields(schemaObject: t.ObjectExpression): ParsedField[] {
209
324
  if (!name) continue
210
325
 
211
326
  const field: ParsedField = { name, required: true }
212
- analyzeFieldExpression(prop.value, field)
327
+ analyzeFieldExpression(prop.value, field, bindings)
213
328
  fields.push(field)
214
329
  }
215
330
  return fields
@@ -219,14 +334,18 @@ function parseSchemaFields(schemaObject: t.ObjectExpression): ParsedField[] {
219
334
  * Walk a field's value expression. Each layer is either a wrapper method call
220
335
  * (`.optional()`, `.default()`, `.nullable()`, `.nullish()`, `.orderBy(...)`)
221
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.
222
341
  */
223
- function analyzeFieldExpression(node: t.Node, field: ParsedField): void {
224
- let current: t.Node | null = node
342
+ function analyzeFieldExpression(node: t.Node, field: ParsedField, bindings: Bindings): void {
343
+ let current: t.Node | null = resolveExpression(node, bindings)
225
344
  while (current) {
226
345
  if (current.type !== 'CallExpression') return
227
346
 
228
347
  if (isBaseCall(current)) {
229
- analyzeBaseCall(current, field)
348
+ analyzeBaseCall(current, field, bindings)
230
349
  return
231
350
  }
232
351
 
@@ -242,7 +361,7 @@ function analyzeFieldExpression(node: t.Node, field: ParsedField): void {
242
361
  field.orderBy = { direction }
243
362
  }
244
363
 
245
- current = current.callee.object
364
+ current = resolveExpression(current.callee.object, bindings)
246
365
  }
247
366
  }
248
367
 
@@ -262,7 +381,7 @@ function isBaseCall(node: t.CallExpression): boolean {
262
381
  return false
263
382
  }
264
383
 
265
- function analyzeBaseCall(node: t.CallExpression, field: ParsedField): void {
384
+ function analyzeBaseCall(node: t.CallExpression, field: ParsedField, bindings: Bindings): void {
266
385
  const callee = node.callee
267
386
 
268
387
  // Bare image() / reference() from the schema callback form
@@ -314,11 +433,26 @@ function analyzeBaseCall(node: t.CallExpression, field: ParsedField): void {
314
433
  return
315
434
  }
316
435
 
317
- // (z|n).array(reference('foo'))array of references
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
318
449
  if ((ns === 'z' || ns === 'n') && fn === 'array') {
319
- const inner = node.arguments[0]
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.
320
454
  if (
321
- inner?.type === 'CallExpression'
455
+ inner.type === 'CallExpression'
322
456
  && inner.callee.type === 'Identifier'
323
457
  && inner.callee.name === 'reference'
324
458
  ) {
@@ -326,7 +460,18 @@ function analyzeBaseCall(node: t.CallExpression, field: ParsedField): void {
326
460
  if (target?.type === 'StringLiteral') {
327
461
  field.reference = { target: target.value, isArray: true }
328
462
  }
463
+ return
329
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
330
475
  return
331
476
  }
332
477
  }
@@ -1,6 +1,7 @@
1
1
  import { signal } from '@preact/signals'
2
2
  import { useMemo, useState } from 'preact/hooks'
3
3
  import { useSearchFilter } from '../hooks/useSearchFilter'
4
+ import { cn } from '../lib/cn'
4
5
  import { deleteMarkdownPage } from '../markdown-api'
5
6
  import {
6
7
  closeCollectionsBrowser,
@@ -241,20 +242,35 @@ export function CollectionsBrowser() {
241
242
  )
242
243
  }
243
244
 
244
- // Collection list
245
+ // Collection list — group nested (child) collections under their parent
246
+ const ordered: Array<{ col: typeof collections[number]; nested: boolean }> = []
247
+ for (const col of collections.filter(c => !c.parentCollection)) {
248
+ ordered.push({ col, nested: false })
249
+ for (const child of collections.filter(c => c.parentCollection === col.name)) {
250
+ ordered.push({ col: child, nested: true })
251
+ }
252
+ }
253
+ // Append any orphaned children whose parent isn't present, so nothing is hidden.
254
+ for (const col of collections.filter(c => c.parentCollection && !collections.some(p => p.name === c.parentCollection))) {
255
+ ordered.push({ col, nested: false })
256
+ }
257
+
245
258
  return (
246
259
  <ModalBackdrop onClose={handleClose} extraClass="flex flex-col max-h-[80vh]">
247
260
  <ModalHeader title="Collections" onClose={handleClose} />
248
261
  <div class="p-5 space-y-2 overflow-y-auto flex-1 min-h-0">
249
- {collections.map((col) => (
262
+ {ordered.map(({ col, nested }) => (
250
263
  <button
251
264
  key={col.name}
252
265
  type="button"
253
266
  onClick={() => selectBrowserCollection(col.name)}
254
- class="group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
267
+ class={cn(
268
+ 'group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer',
269
+ nested && 'ml-8 w-[calc(100%-2rem)] border-l-2 border-l-cms-primary/40',
270
+ )}
255
271
  data-cms-ui
256
272
  >
257
- <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center">
273
+ <div class={cn('shrink-0 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center', nested ? 'w-8 h-8' : 'w-10 h-10')}>
258
274
  <CollectionIcon />
259
275
  </div>
260
276
  <div class="flex-1 min-w-0">