@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.
- package/dist/editor.js +15910 -15027
- package/package.json +1 -1
- package/src/collection-scanner.ts +127 -13
- package/src/content-config-ast.ts +91 -24
- package/src/editor/components/attribute-editor.tsx +0 -1
- package/src/editor/components/bg-image-overlay.tsx +7 -8
- package/src/editor/components/block-editor.tsx +12 -12
- package/src/editor/components/collections-browser.tsx +10 -10
- package/src/editor/components/create-page-modal.tsx +18 -18
- package/src/editor/components/delete-page-dialog.tsx +4 -3
- package/src/editor/components/field-utils.ts +54 -0
- package/src/editor/components/fields.tsx +516 -73
- package/src/editor/components/frontmatter-fields.tsx +188 -55
- package/src/editor/components/frontmatter-sidebar.tsx +56 -58
- package/src/editor/components/link-edit-popover.tsx +10 -5
- package/src/editor/components/markdown-editor-overlay.tsx +100 -39
- package/src/editor/components/markdown-inline-editor.tsx +58 -26
- package/src/editor/components/mdx-block-view.tsx +4 -4
- package/src/editor/components/mdx-component-picker.tsx +2 -2
- package/src/editor/components/media-library.tsx +19 -18
- package/src/editor/components/modal-shell.tsx +16 -3
- package/src/editor/components/prop-editor.tsx +15 -18
- package/src/editor/components/redirects-manager.tsx +42 -35
- package/src/editor/components/reference-picker.tsx +5 -4
- package/src/editor/components/seo-editor.tsx +36 -27
- package/src/editor/components/toolbar.tsx +50 -33
- package/src/editor/dom.ts +13 -2
- package/src/editor/editor.ts +7 -6
- package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
- package/src/editor/index.tsx +7 -6
- package/src/editor/signals.ts +44 -13
- package/src/editor/strings.ts +123 -0
- package/src/editor/styles.css +75 -2
- package/src/editor/types.ts +8 -0
- package/src/field-types.ts +15 -0
- package/src/index.ts +6 -0
- package/src/types.ts +7 -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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
signals.isBgImageOverlayOpen.value = false
|
|
121
121
|
panelTargetRef.current = null
|
|
122
122
|
} else if (cmsId && element) {
|
|
123
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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>
|