@nuasite/cms 0.39.2 → 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.
Files changed (37) hide show
  1. package/dist/editor.js +15910 -15027
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +127 -13
  4. package/src/content-config-ast.ts +91 -24
  5. package/src/editor/components/attribute-editor.tsx +0 -1
  6. package/src/editor/components/bg-image-overlay.tsx +7 -8
  7. package/src/editor/components/block-editor.tsx +12 -12
  8. package/src/editor/components/collections-browser.tsx +10 -10
  9. package/src/editor/components/create-page-modal.tsx +18 -18
  10. package/src/editor/components/delete-page-dialog.tsx +4 -3
  11. package/src/editor/components/field-utils.ts +54 -0
  12. package/src/editor/components/fields.tsx +516 -73
  13. package/src/editor/components/frontmatter-fields.tsx +188 -55
  14. package/src/editor/components/frontmatter-sidebar.tsx +56 -58
  15. package/src/editor/components/link-edit-popover.tsx +10 -5
  16. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  17. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  18. package/src/editor/components/mdx-block-view.tsx +4 -4
  19. package/src/editor/components/mdx-component-picker.tsx +2 -2
  20. package/src/editor/components/media-library.tsx +19 -18
  21. package/src/editor/components/modal-shell.tsx +16 -3
  22. package/src/editor/components/prop-editor.tsx +15 -18
  23. package/src/editor/components/redirects-manager.tsx +42 -35
  24. package/src/editor/components/reference-picker.tsx +5 -4
  25. package/src/editor/components/seo-editor.tsx +36 -27
  26. package/src/editor/components/toolbar.tsx +50 -33
  27. package/src/editor/dom.ts +13 -2
  28. package/src/editor/editor.ts +7 -6
  29. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  30. package/src/editor/index.tsx +7 -6
  31. package/src/editor/signals.ts +44 -13
  32. package/src/editor/strings.ts +123 -0
  33. package/src/editor/styles.css +75 -2
  34. package/src/editor/types.ts +8 -0
  35. package/src/field-types.ts +15 -0
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +7 -0
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.39.2",
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
 
@@ -50,6 +50,25 @@ const FREE_TEXT_FIELD_NAMES = new Set([
50
50
  'caption',
51
51
  ])
52
52
 
53
+ /** Normalized names (lowercased, underscores/hyphens stripped) that mark a field as the publish toggle. */
54
+ const PUBLISH_TOGGLE_NAMES = new Set(['draft', 'isdraft', 'published', 'ispublished', 'unpublished'])
55
+
56
+ /** Normalized names that mark a field as the publish/release date anchor. */
57
+ const PUBLISH_DATE_NAMES = new Set([
58
+ 'date',
59
+ 'pubdate',
60
+ 'publishdate',
61
+ 'publisheddate',
62
+ 'publishedate',
63
+ 'publishedat',
64
+ 'datepublished',
65
+ ])
66
+
67
+ /** Normalize a field name for case- and separator-insensitive matching against the *_NAMES sets above. */
68
+ function normalizeFieldName(name: string): string {
69
+ return name.toLowerCase().replace(/[_-]/g, '')
70
+ }
71
+
53
72
  /**
54
73
  * Observed values for a single field across multiple files
55
74
  */
@@ -121,7 +140,7 @@ function assignFieldMetadata(
121
140
  ): void {
122
141
  for (const field of fields) {
123
142
  // Scanner defaults: well-known fields go to sidebar
124
- if (SIDEBAR_FIELD_NAMES.has(field.name.toLowerCase()) || field.type === 'image' || field.type === 'boolean') {
143
+ if (SIDEBAR_FIELD_NAMES.has(normalizeFieldName(field.name)) || field.type === 'image' || field.type === 'boolean') {
125
144
  field.position = 'sidebar'
126
145
  } else {
127
146
  field.position = 'header'
@@ -194,9 +213,14 @@ function inferFieldType(value: unknown, key: string): FieldType {
194
213
  }
195
214
 
196
215
  /**
197
- * 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.
198
219
  */
199
- 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 []
200
224
  const fields: FieldDefinition[] = []
201
225
 
202
226
  for (const obs of observations) {
@@ -228,7 +252,7 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
228
252
  }
229
253
 
230
254
  // For text fields, check if we should treat as select (limited unique values)
231
- if (fieldType === 'text' && !FREE_TEXT_FIELD_NAMES.has(obs.name.toLowerCase())) {
255
+ if (fieldType === 'text' && !FREE_TEXT_FIELD_NAMES.has(normalizeFieldName(obs.name))) {
232
256
  const uniqueValues = [...new Set(nonNullValues.map(v => String(v)))]
233
257
  const uniqueRatio = uniqueValues.length / nonNullValues.length
234
258
  // Only treat as select if unique values are limited AND not nearly all unique
@@ -264,12 +288,26 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
264
288
  for (const item of objectItems) {
265
289
  collectFieldObservations(subFieldMap, item, objectItems.length)
266
290
  }
267
- field.fields = mergeFieldObservations(Array.from(subFieldMap.values()))
291
+ field.fields = mergeFieldObservations(Array.from(subFieldMap.values()), depth + 1)
268
292
  }
269
293
  }
270
294
  }
271
295
  }
272
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
+
273
311
  fields.push(field)
274
312
  }
275
313
 
@@ -437,17 +475,65 @@ function applyParsedConfig(
437
475
  for (const pf of parsedColl.fields) {
438
476
  const field = fieldsByName.get(pf.name)
439
477
  if (!field) continue
440
- if (pf.type) {
441
- field.type = pf.type
442
- if (pf.options) field.options = pf.options
443
- }
444
- if (pf.hints) field.hints = pf.hints
445
- if (pf.astroImage) field.astroImage = true
446
- field.required = pf.required
478
+ applyParsedFieldOverrides(field, pf)
447
479
  }
448
480
  }
449
481
  }
450
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
+
451
537
  /** Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries. */
452
538
  function applyCollectionOrderBy(
453
539
  collections: Record<string, CollectionDefinition>,
@@ -583,6 +669,33 @@ function detectReferenceFieldsBySlugMatch(collections: Record<string, Collection
583
669
  }
584
670
  }
585
671
 
672
+ /**
673
+ * Tag fields with semantic roles so the editor UI can position them without
674
+ * matching on Astro-specific field names. Detection lives here — the layer
675
+ * that already knows it's parsing Astro content collections.
676
+ */
677
+ function assignSemanticRoles(collections: Record<string, CollectionDefinition>): void {
678
+ for (const def of Object.values(collections)) {
679
+ let toggle: FieldDefinition | undefined
680
+ let dateByName: FieldDefinition | undefined
681
+ let dateByType: FieldDefinition | undefined
682
+ for (const field of def.fields) {
683
+ if (field.hidden || field.role) continue
684
+ const normalized = normalizeFieldName(field.name)
685
+ if (!toggle && field.type === 'boolean' && PUBLISH_TOGGLE_NAMES.has(normalized)) {
686
+ toggle = field
687
+ } else if (!dateByName && PUBLISH_DATE_NAMES.has(normalized)) {
688
+ dateByName = field
689
+ } else if (!dateByType && (field.type === 'date' || field.type === 'datetime')) {
690
+ dateByType = field
691
+ }
692
+ }
693
+ if (toggle) toggle.role = 'publish-toggle'
694
+ const date = dateByName ?? dateByType
695
+ if (date) date.role = 'publish-date'
696
+ }
697
+ }
698
+
586
699
  /** Suffixes that indicate a field is a derived href/url/slug companion */
587
700
  const HREF_SUFFIXES = ['href', 'url', 'link', 'slug', 'path'] as const
588
701
 
@@ -749,6 +862,7 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
749
862
  applyParsedConfig(collections, parsed)
750
863
  detectReferenceFields(collections, parsed)
751
864
  detectDerivedHrefFields(collections)
865
+ assignSemanticRoles(collections)
752
866
  applyCollectionOrderBy(collections, parsed)
753
867
 
754
868
  return collections
@@ -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
  }
@@ -329,7 +329,6 @@ function AttributeField({ attrName, currentAttr, originalAttr, pages, onUpdate,
329
329
  <ImageField
330
330
  label={config.label}
331
331
  value={currentValue || undefined}
332
- placeholder={config.placeholder}
333
332
  onChange={(v) => onUpdate(v)}
334
333
  onBrowse={onOpenMediaLibrary}
335
334
  isDirty={isDirty}
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
1
+ import { useCallback, useEffect, useRef } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
3
  import { isApplyingUndoRedo, recordChange } from '../history'
4
4
  import { cn } from '../lib/cn'
@@ -80,14 +80,14 @@ const REPEAT_OPTIONS = [
80
80
  * Shows a floating badge on hover and opens a right-side settings panel on click.
81
81
  */
82
82
  export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlayProps) {
83
- const [panelOpen, setPanelOpen] = useState(false)
83
+ const panelOpen = signals.isBgImageOverlayOpen.value
84
84
  // Capture target when panel opens so it stays stable when hover moves away
85
85
  const panelTargetRef = useRef<{ cmsId: string; element: HTMLElement } | null>(null)
86
86
 
87
87
  // Close panel when hovering a different bg-image element
88
88
  useEffect(() => {
89
89
  if (cmsId && panelTargetRef.current && cmsId !== panelTargetRef.current.cmsId) {
90
- setPanelOpen(false)
90
+ signals.isBgImageOverlayOpen.value = false
91
91
  panelTargetRef.current = null
92
92
  }
93
93
  }, [cmsId])
@@ -99,7 +99,7 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
99
99
  const handleClickOutside = (e: MouseEvent) => {
100
100
  const target = e.target as HTMLElement
101
101
  if (target.closest('[data-cms-ui]')) return
102
- setPanelOpen(false)
102
+ signals.isBgImageOverlayOpen.value = false
103
103
  panelTargetRef.current = null
104
104
  }
105
105
 
@@ -117,16 +117,16 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
117
117
  e.preventDefault()
118
118
  e.stopPropagation()
119
119
  if (panelOpen) {
120
- setPanelOpen(false)
120
+ signals.isBgImageOverlayOpen.value = false
121
121
  panelTargetRef.current = null
122
122
  } else if (cmsId && element) {
123
- setPanelOpen(true)
123
+ signals.isBgImageOverlayOpen.value = true
124
124
  panelTargetRef.current = { cmsId, element }
125
125
  }
126
126
  }, [panelOpen, cmsId, element])
127
127
 
128
128
  const handleClose = useCallback(() => {
129
- setPanelOpen(false)
129
+ signals.isBgImageOverlayOpen.value = false
130
130
  panelTargetRef.current = null
131
131
  }, [])
132
132
 
@@ -243,7 +243,6 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
243
243
  <ImageField
244
244
  label="Image URL"
245
245
  value={currentUrl || undefined}
246
- placeholder="/assets/image.png"
247
246
  onChange={handleImageUrlChange}
248
247
  onBrowse={handleBrowse}
249
248
  isDirty={isImageDirty}
@@ -374,13 +374,13 @@ export function BlockEditor({
374
374
  <div class="flex gap-2">
375
375
  <button
376
376
  onClick={() => handleStartInsert('before')}
377
- class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
377
+ class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-sm cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
378
378
  >
379
379
  <span class="text-base">↑</span> {isArrayItem ? 'Add item before' : 'Insert before'}
380
380
  </button>
381
381
  <button
382
382
  onClick={() => handleStartInsert('after')}
383
- class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
383
+ class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-sm cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
384
384
  >
385
385
  <span class="text-base">↓</span> {isArrayItem ? 'Add item after' : 'Insert after'}
386
386
  </button>
@@ -388,20 +388,20 @@ export function BlockEditor({
388
388
  <div class="flex gap-2 justify-between">
389
389
  <button
390
390
  onClick={() => setMode('confirm-remove')}
391
- class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
391
+ class="px-5 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
392
392
  >
393
393
  {isArrayItem ? 'Remove item' : 'Remove'}
394
394
  </button>
395
395
  <div class="flex gap-2">
396
396
  <button
397
397
  onClick={onClose}
398
- class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
398
+ class="px-5 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
399
399
  >
400
400
  Cancel
401
401
  </button>
402
402
  <button
403
403
  onClick={handleSave}
404
- class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
404
+ class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
405
405
  >
406
406
  Save
407
407
  </button>
@@ -413,7 +413,7 @@ export function BlockEditor({
413
413
  : mode === 'confirm-remove'
414
414
  ? (
415
415
  <div class="text-center p-5">
416
- <div class="px-4 py-3 bg-red-500/10 border border-red-500/30 rounded-cms-md mb-5 text-[13px] text-white">
416
+ <div class="px-4 py-3 bg-red-500/10 border border-red-500/30 rounded-cms-sm mb-5 text-[13px] text-white">
417
417
  {isArrayItem
418
418
  ? (
419
419
  <>
@@ -429,7 +429,7 @@ export function BlockEditor({
429
429
  <div class="flex gap-2 justify-end pt-4 border-t border-white/10 mt-4">
430
430
  <button
431
431
  onClick={handleBackToEdit}
432
- class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
432
+ class="px-5 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
433
433
  >
434
434
  Cancel
435
435
  </button>
@@ -440,7 +440,7 @@ export function BlockEditor({
440
440
  onClose()
441
441
  }
442
442
  }}
443
- class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
443
+ class="px-5 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
444
444
  >
445
445
  {isArrayItem ? 'Confirm remove item' : 'Confirm remove'}
446
446
  </button>
@@ -452,7 +452,7 @@ export function BlockEditor({
452
452
  <div class="p-5">
453
453
  {/* New component props */}
454
454
  <div class="mb-5">
455
- <div class="px-4 py-3 bg-white/10 rounded-cms-md mb-4 text-[13px] text-white">
455
+ <div class="px-4 py-3 bg-white/10 rounded-cms-sm mb-4 text-[13px] text-white">
456
456
  {isArrayItem
457
457
  ? (
458
458
  <>
@@ -478,13 +478,13 @@ export function BlockEditor({
478
478
  <div class="flex gap-2 justify-end pt-4 border-t border-white/10 mt-4">
479
479
  <button
480
480
  onClick={() => isArrayItem ? handleBackToEdit() : setMode('insert-picker')}
481
- class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
481
+ class="px-5 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
482
482
  >
483
483
  Back
484
484
  </button>
485
485
  <button
486
486
  onClick={handleConfirmInsert}
487
- class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
487
+ class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
488
488
  >
489
489
  {isArrayItem ? 'Add item' : 'Insert component'}
490
490
  </button>
@@ -510,7 +510,7 @@ export function BlockEditor({
510
510
  <div class="mt-5 pt-4 border-t border-white/10">
511
511
  <button
512
512
  onClick={handleBackToEdit}
513
- class="w-full px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
513
+ class="w-full px-4 py-2 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
514
514
  >
515
515
  Back to edit
516
516
  </button>