@nuasite/cms 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.26.0",
17
+ "version": "0.28.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
4
4
  import { getProjectRoot } from './config'
5
5
  import { slugifyHref } from './shared'
6
- import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
6
+ import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldHints, FieldType } from './types'
7
7
 
8
8
  /** Regex patterns for type inference */
9
9
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
@@ -31,7 +31,6 @@ const SIDEBAR_FIELD_NAMES = new Set([
31
31
  'author',
32
32
  ])
33
33
 
34
- /** Directive pattern: # @position <value> or # @group <value> */
35
34
  /** Matches `@position <value>` or `@group <value>` in YAML comment text (# already stripped by parser) */
36
35
  const DIRECTIVE_PATTERN = /^\s*@(position|group)\s+(.+)$/
37
36
 
@@ -400,10 +399,6 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
400
399
  const fullPath = path.join(projectRoot, configPath)
401
400
  const content = await fs.readFile(fullPath, 'utf-8')
402
401
 
403
- const collectionBlocks = content.matchAll(
404
- /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*z\.object\s*\(\s*\{([\s\S]*?)\}\s*\)/g,
405
- )
406
-
407
402
  // Map variable names to collection names from exports
408
403
  const varToName = new Map<string, string>()
409
404
  const exportMatch = content.match(/export\s+const\s+collections\s*=\s*\{([\s\S]*?)\}/)
@@ -414,13 +409,32 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
414
409
  }
415
410
  }
416
411
 
412
+ // Find schema block starts via regex, then extract bodies with brace counting
413
+ // to correctly handle nested objects like n.number({ min: 1, max: 100 })
414
+ const schemaStart = /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*(?:z|n)\.object\s*\(\s*\{/g
417
415
  const blocks: Array<{ collectionName: string; schemaBody: string }> = []
418
- for (const block of collectionBlocks) {
419
- const varName = block[1]
420
- const schemaBody = block[2]!
416
+
417
+ let match
418
+ while ((match = schemaStart.exec(content)) !== null) {
419
+ const varName = match[1]
421
420
  const collectionName = varName ? varToName.get(varName) : undefined
422
421
  if (!collectionName) continue
423
- blocks.push({ collectionName, schemaBody })
422
+
423
+ // Brace-balanced extraction: the regex consumed the opening {,
424
+ // so start at depth 1 and scan forward for the matching }
425
+ const bodyStart = match.index + match[0].length
426
+ let depth = 1
427
+ let i = bodyStart
428
+ while (i < content.length && depth > 0) {
429
+ if (content[i] === '{') depth++
430
+ else if (content[i] === '}') depth--
431
+ i++
432
+ }
433
+
434
+ if (depth === 0) {
435
+ // i is one past the matching }, so body is [bodyStart, i-1)
436
+ blocks.push({ collectionName, schemaBody: content.slice(bodyStart, i - 1) })
437
+ }
424
438
  }
425
439
 
426
440
  if (blocks.length > 0) return blocks
@@ -455,7 +469,7 @@ function parseContentConfigReferences(
455
469
  }
456
470
 
457
471
  /** Valid field type names exported by `n` helper from @nuasite/cms */
458
- const FIELD_HELPER_TYPES = new Set(['image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
472
+ const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
459
473
 
460
474
  /**
461
475
  * Parse the content config file to extract explicit field type hints:
@@ -500,6 +514,54 @@ function parseContentConfigFieldTypes(
500
514
  return result
501
515
  }
502
516
 
517
+ /**
518
+ * Parse the content config to find `.orderBy('asc'|'desc')` markers on fields.
519
+ * Matches patterns like `fieldName: n.number().orderBy('asc')`.
520
+ * Returns a map: collectionName → { field, direction }.
521
+ */
522
+ function parseContentConfigOrderBy(
523
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
524
+ ): Map<string, { field: string; direction: 'asc' | 'desc' }> {
525
+ const result = new Map<string, { field: string; direction: 'asc' | 'desc' }>()
526
+ for (const { collectionName, schemaBody } of schemaBlocks) {
527
+ const match = schemaBody.match(/(\w+)\s*:.*\.orderBy\s*\(\s*(?:['"](\w+)['"])?\s*\)/)
528
+ if (match) {
529
+ const direction = match[2] === 'desc' ? 'desc' as const : 'asc' as const
530
+ result.set(collectionName, { field: match[1]!, direction })
531
+ }
532
+ }
533
+ return result
534
+ }
535
+
536
+ /**
537
+ * Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries.
538
+ */
539
+ function applyCollectionOrderBy(
540
+ collections: Record<string, CollectionDefinition>,
541
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
542
+ ): void {
543
+ const orderByFields = parseContentConfigOrderBy(schemaBlocks)
544
+ for (const [collectionName, { field: fieldName, direction }] of orderByFields) {
545
+ const def = collections[collectionName]
546
+ if (!def) continue
547
+ def.orderBy = fieldName
548
+ def.orderDirection = direction
549
+ if (def.entries && def.entries.length > 1) {
550
+ const dir = direction === 'desc' ? -1 : 1
551
+ def.entries.sort((a, b) => {
552
+ const aVal = a.data?.[fieldName]
553
+ const bVal = b.data?.[fieldName]
554
+ if (aVal == null && bVal == null) return 0
555
+ if (aVal == null) return 1
556
+ if (bVal == null) return -1
557
+ if (typeof aVal === 'number' && typeof bVal === 'number') return (aVal - bVal) * dir
558
+ if (aVal instanceof Date && bVal instanceof Date) return (aVal.getTime() - bVal.getTime()) * dir
559
+ return String(aVal).localeCompare(String(bVal)) * dir
560
+ })
561
+ }
562
+ }
563
+ }
564
+
503
565
  /**
504
566
  * Extract all top-level field names from a schema body string.
505
567
  * Matches `fieldName:` patterns at the start of lines within z.object({...}).
@@ -552,6 +614,82 @@ function applyConfigFieldTypes(
552
614
  }
553
615
  }
554
616
 
617
+ /** All recognized hint keys */
618
+ const VALID_HINT_KEYS = new Set(['min', 'max', 'step', 'placeholder', 'maxLength', 'minLength', 'rows', 'accept'])
619
+ /** Subset of hint keys that take numeric values */
620
+ const NUMERIC_HINT_KEYS = new Set(['min', 'max', 'step', 'maxLength', 'minLength', 'rows'])
621
+
622
+ /**
623
+ * Parse `n.type({ key: value, ... })` options objects from schema blocks.
624
+ * Returns a map: collectionName → fieldName → FieldHints.
625
+ */
626
+ function parseContentConfigFieldHints(
627
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
628
+ ): Map<string, Map<string, FieldHints>> {
629
+ const result = new Map<string, Map<string, FieldHints>>()
630
+
631
+ for (const { collectionName, schemaBody } of schemaBlocks) {
632
+ const fields = new Map<string, FieldHints>()
633
+
634
+ // Match: fieldName: n.helperName({ ...options })
635
+ const fieldMatches = schemaBody.matchAll(/(\w+)\s*:\s*n\.\w+\s*\(\s*\{([\s\S]*?)}\s*\)/g)
636
+ for (const m of fieldMatches) {
637
+ const fieldName = m[1]!
638
+ const optionsBody = m[2]!
639
+ const raw: Record<string, string | number> = {}
640
+
641
+ // Extract key-value pairs from the options body
642
+ const kvMatches = optionsBody.matchAll(/(\w+)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?[\d.]+))/g)
643
+ for (const kv of kvMatches) {
644
+ const key = kv[1]!
645
+ if (!VALID_HINT_KEYS.has(key)) continue
646
+ const strValue = kv[2] ?? kv[3]
647
+ const numValue = kv[4]
648
+
649
+ if (numValue != null && NUMERIC_HINT_KEYS.has(key)) {
650
+ raw[key] = Number(numValue)
651
+ } else if (strValue != null) {
652
+ if (NUMERIC_HINT_KEYS.has(key)) {
653
+ const parsed = Number(strValue)
654
+ raw[key] = Number.isNaN(parsed) ? strValue : parsed
655
+ } else {
656
+ raw[key] = strValue
657
+ }
658
+ }
659
+ }
660
+ const hints = raw as FieldHints
661
+
662
+ if (Object.keys(hints).length > 0) {
663
+ fields.set(fieldName, hints)
664
+ }
665
+ }
666
+
667
+ if (fields.size > 0) {
668
+ result.set(collectionName, fields)
669
+ }
670
+ }
671
+ return result
672
+ }
673
+
674
+ /**
675
+ * Apply field hints from content config parsing to scanned collections.
676
+ */
677
+ function applyConfigFieldHints(
678
+ collections: Record<string, CollectionDefinition>,
679
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
680
+ ): void {
681
+ const configHints = parseContentConfigFieldHints(schemaBlocks)
682
+ for (const [collectionName, fieldHints] of configHints) {
683
+ const def = collections[collectionName]
684
+ if (!def) continue
685
+ for (const [fieldName, hints] of fieldHints) {
686
+ const field = def.fields.find(f => f.name === fieldName)
687
+ if (!field) continue
688
+ field.hints = hints
689
+ }
690
+ }
691
+ }
692
+
555
693
  /**
556
694
  * After all collections are scanned, detect reference fields.
557
695
  * Prefers explicit reference() declarations from the content config file.
@@ -787,12 +925,14 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
787
925
  // Content directory doesn't exist or isn't readable
788
926
  }
789
927
 
790
- // Post-scan: apply explicit type hints, detect references, and derived fields
928
+ // Post-scan: apply explicit type hints, field hints, detect references, derived fields, and ordering
791
929
  const schemaBlocks = await parseContentConfigSchemaBlocks()
792
930
  filterFieldsBySchema(collections, schemaBlocks)
793
931
  applyConfigFieldTypes(collections, schemaBlocks)
932
+ applyConfigFieldHints(collections, schemaBlocks)
794
933
  await detectReferenceFields(collections, schemaBlocks)
795
934
  detectDerivedHrefFields(collections)
935
+ applyCollectionOrderBy(collections, schemaBlocks)
796
936
 
797
937
  return collections
798
938
  }
@@ -302,6 +302,13 @@ export function createDevMiddleware(
302
302
  return res.end(chunk, ...args)
303
303
  }
304
304
 
305
+ // Skip CMS processing for internal preview pages
306
+ if (requestUrl.startsWith('/_nua/preview')) {
307
+ res.write = originalWrite
308
+ res.end = originalEnd
309
+ return (res.end as any)(chunk, ...args)
310
+ }
311
+
305
312
  if (chunk) {
306
313
  chunks!.push(Buffer.from(chunk))
307
314
  }
@@ -39,13 +39,15 @@ export interface TextFieldProps {
39
39
  label: string
40
40
  value: string | undefined
41
41
  placeholder?: string
42
+ maxLength?: number
43
+ minLength?: number
42
44
  onChange: (value: string) => void
43
45
  isDirty?: boolean
44
46
  onReset?: () => void
45
47
  inputType?: string
46
48
  }
47
49
 
48
- export function TextField({ label, value, placeholder, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
50
+ export function TextField({ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
49
51
  return (
50
52
  <div class="space-y-1.5">
51
53
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -53,6 +55,8 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
53
55
  type={inputType}
54
56
  value={value ?? ''}
55
57
  placeholder={placeholder}
58
+ maxLength={maxLength}
59
+ minLength={minLength}
56
60
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
57
61
  class={cn(
58
62
  'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
@@ -270,12 +274,13 @@ export interface NumberFieldProps {
270
274
  placeholder?: string
271
275
  min?: number
272
276
  max?: number
277
+ step?: number
273
278
  onChange: (value: number | undefined) => void
274
279
  isDirty?: boolean
275
280
  onReset?: () => void
276
281
  }
277
282
 
278
- export function NumberField({ label, value, placeholder, min, max, onChange, isDirty, onReset }: NumberFieldProps) {
283
+ export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset }: NumberFieldProps) {
279
284
  return (
280
285
  <div class="space-y-1.5">
281
286
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -285,6 +290,7 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
285
290
  placeholder={placeholder}
286
291
  min={min}
287
292
  max={max}
293
+ step={step}
288
294
  onInput={(e) => {
289
295
  const val = (e.target as HTMLInputElement).value
290
296
  onChange(val === '' ? undefined : Number(val))
@@ -395,6 +395,7 @@ export function SchemaFrontmatterField({
395
395
  onChange,
396
396
  }: SchemaFrontmatterFieldProps) {
397
397
  const label = formatFieldLabel(field.name)
398
+ const hints = field.hints
398
399
 
399
400
  switch (field.type) {
400
401
  case 'text':
@@ -404,7 +405,9 @@ export function SchemaFrontmatterField({
404
405
  <TextField
405
406
  label={label}
406
407
  value={(value as string) ?? ''}
407
- placeholder={getPlaceholder(field)}
408
+ placeholder={hints?.placeholder ?? getPlaceholder(field)}
409
+ maxLength={hints?.maxLength as number | undefined}
410
+ minLength={hints?.minLength as number | undefined}
408
411
  onChange={(v) => onChange(v)}
409
412
  inputType={field.type === 'text' ? undefined : field.type}
410
413
  />
@@ -442,8 +445,9 @@ export function SchemaFrontmatterField({
442
445
  <textarea
443
446
  value={(value as string) ?? ''}
444
447
  onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
445
- placeholder={getPlaceholder(field)}
446
- rows={3}
448
+ placeholder={hints?.placeholder ?? getPlaceholder(field)}
449
+ rows={hints?.rows ?? 3}
450
+ maxLength={hints?.maxLength as number | undefined}
447
451
  class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
448
452
  data-cms-ui
449
453
  />
@@ -459,6 +463,8 @@ export function SchemaFrontmatterField({
459
463
  <input
460
464
  type={field.type === 'datetime' ? 'datetime-local' : field.type}
461
465
  value={(value as string) ?? ''}
466
+ min={hints?.min != null ? String(hints.min) : undefined}
467
+ max={hints?.max != null ? String(hints.max) : undefined}
462
468
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
463
469
  class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
464
470
  data-cms-ui
@@ -471,6 +477,10 @@ export function SchemaFrontmatterField({
471
477
  <NumberField
472
478
  label={label}
473
479
  value={(value as number) ?? undefined}
480
+ placeholder={hints?.placeholder}
481
+ min={typeof hints?.min === 'number' ? hints.min : undefined}
482
+ max={typeof hints?.max === 'number' ? hints.max : undefined}
483
+ step={hints?.step}
474
484
  onChange={(v) => onChange(v ?? 0)}
475
485
  />
476
486
  )
@@ -0,0 +1,232 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { HighlightMatch } from './fields'
3
+
4
+ export interface LinkSuggestion {
5
+ value: string
6
+ label: string
7
+ description?: string
8
+ }
9
+
10
+ export interface LinkEditPopoverProps {
11
+ initialUrl: string
12
+ suggestions?: LinkSuggestion[]
13
+ onApply: (url: string) => void
14
+ onRemove?: () => void
15
+ onClose: () => void
16
+ /** Use static positioning instead of absolute (for inline contexts) */
17
+ inline?: boolean
18
+ }
19
+
20
+ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, onClose, inline }: LinkEditPopoverProps) {
21
+ const inputRef = useRef<HTMLInputElement>(null)
22
+ const rootRef = useRef<HTMLDivElement>(null)
23
+ const listRef = useRef<HTMLDivElement>(null)
24
+ const [query, setQuery] = useState(initialUrl)
25
+ const [showSuggestions, setShowSuggestions] = useState(false)
26
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
27
+
28
+ const filtered = useMemo(() => {
29
+ if (!suggestions?.length) return []
30
+ if (!query || query === 'https://') return suggestions
31
+ const q = query.toLowerCase()
32
+ return suggestions.filter(
33
+ o => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
34
+ )
35
+ }, [query, suggestions])
36
+
37
+ useEffect(() => {
38
+ const input = inputRef.current
39
+ if (input) {
40
+ input.focus()
41
+ input.select()
42
+ }
43
+ }, [])
44
+
45
+ // Close on click outside — uses `click` in bubble phase so form submit
46
+ // (which fires synchronously during the button's click) completes first
47
+ useEffect(() => {
48
+ const handler = (e: MouseEvent) => {
49
+ if (rootRef.current && !e.composedPath().includes(rootRef.current)) {
50
+ onClose()
51
+ }
52
+ }
53
+ document.addEventListener('click', handler)
54
+ return () => document.removeEventListener('click', handler)
55
+ }, [onClose])
56
+
57
+ // Scroll highlighted item into view
58
+ useEffect(() => {
59
+ if (highlightedIndex >= 0 && listRef.current) {
60
+ const item = listRef.current.children[highlightedIndex] as HTMLElement | undefined
61
+ item?.scrollIntoView({ block: 'nearest' })
62
+ }
63
+ }, [highlightedIndex])
64
+
65
+ const handleSubmit = useCallback((e: Event) => {
66
+ e.preventDefault()
67
+ const url = inputRef.current?.value.trim()
68
+ if (url) {
69
+ onApply(url)
70
+ }
71
+ }, [onApply])
72
+
73
+ const selectOption = useCallback((value: string) => {
74
+ if (inputRef.current) inputRef.current.value = value
75
+ setQuery(value)
76
+ setShowSuggestions(false)
77
+ onApply(value)
78
+ }, [onApply])
79
+
80
+ const handleInput = useCallback((e: Event) => {
81
+ const v = (e.target as HTMLInputElement).value
82
+ setQuery(v)
83
+ setShowSuggestions(true)
84
+ setHighlightedIndex(-1)
85
+ }, [])
86
+
87
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
88
+ if (e.key === 'Escape') {
89
+ if (showSuggestions && filtered.length > 0) {
90
+ e.preventDefault()
91
+ e.stopPropagation()
92
+ setShowSuggestions(false)
93
+ return
94
+ }
95
+ e.preventDefault()
96
+ e.stopPropagation()
97
+ onClose()
98
+ return
99
+ }
100
+
101
+ if (!showSuggestions || filtered.length === 0) return
102
+
103
+ if (e.key === 'ArrowDown') {
104
+ e.preventDefault()
105
+ setHighlightedIndex(i => Math.min(i + 1, filtered.length - 1))
106
+ } else if (e.key === 'ArrowUp') {
107
+ e.preventDefault()
108
+ setHighlightedIndex(i => Math.max(i - 1, 0))
109
+ } else if (e.key === 'Enter' && highlightedIndex >= 0) {
110
+ e.preventDefault()
111
+ selectOption(filtered[highlightedIndex]!.value)
112
+ }
113
+ }, [showSuggestions, filtered, highlightedIndex, selectOption, onClose])
114
+
115
+ const handleFocus = useCallback(() => {
116
+ setShowSuggestions(true)
117
+ }, [])
118
+
119
+ const handleBlur = useCallback(() => {
120
+ setTimeout(() => setShowSuggestions(false), 150)
121
+ }, [])
122
+
123
+ const showDropdown = showSuggestions && filtered.length > 0
124
+
125
+ return (
126
+ <div
127
+ ref={rootRef}
128
+ class={inline ? 'slide-in' : 'relative z-[9999] slide-in shrink-0'}
129
+ data-cms-ui
130
+ >
131
+ <form
132
+ onSubmit={handleSubmit}
133
+ class={`flex items-center gap-2 ${inline ? 'py-1.5' : 'px-4 py-2.5 bg-cms-dark border-b border-white/10'}`}
134
+ >
135
+ <svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
136
+ <path
137
+ stroke-linecap="round"
138
+ stroke-linejoin="round"
139
+ d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
140
+ />
141
+ </svg>
142
+
143
+ <div class="flex-1 min-w-0 relative">
144
+ <input
145
+ ref={inputRef}
146
+ type="text"
147
+ defaultValue={initialUrl}
148
+ placeholder="https://example.com or /page"
149
+ onInput={handleInput}
150
+ onFocus={handleFocus}
151
+ onBlur={handleBlur}
152
+ onKeyDown={handleKeyDown}
153
+ autocomplete="off"
154
+ class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-cms-primary/50 transition-colors"
155
+ data-cms-ui
156
+ />
157
+ {showDropdown && (
158
+ <div
159
+ ref={listRef}
160
+ class="absolute z-50 left-0 right-0 mt-1 max-h-48 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
161
+ data-cms-ui
162
+ >
163
+ {filtered.map((opt, i) => (
164
+ <button
165
+ key={opt.value}
166
+ type="button"
167
+ onMouseDown={(e) => {
168
+ e.preventDefault()
169
+ selectOption(opt.value)
170
+ }}
171
+ class={`w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer ${
172
+ i === highlightedIndex
173
+ ? 'bg-white/15 text-white'
174
+ : 'text-white/70 hover:bg-white/10 hover:text-white'
175
+ }`}
176
+ data-cms-ui
177
+ >
178
+ <span class="block truncate font-medium">
179
+ <HighlightMatch text={opt.label} query={query === 'https://' ? '' : query} />
180
+ </span>
181
+ {opt.description && (
182
+ <span class="block truncate text-white/40">
183
+ <HighlightMatch text={opt.description} query={query === 'https://' ? '' : query} />
184
+ </span>
185
+ )}
186
+ </button>
187
+ ))}
188
+ </div>
189
+ )}
190
+ </div>
191
+
192
+ <button
193
+ type="submit"
194
+ class="px-3 py-1.5 bg-cms-primary text-cms-primary-text text-[12px] font-medium rounded-cms-sm hover:bg-cms-primary-hover transition-colors shrink-0"
195
+ data-cms-ui
196
+ >
197
+ Apply
198
+ </button>
199
+
200
+ {onRemove && (
201
+ <button
202
+ type="button"
203
+ onClick={onRemove}
204
+ class="p-1.5 text-white/40 hover:text-red-400 hover:bg-red-500/10 rounded-cms-sm transition-colors shrink-0"
205
+ title="Remove link"
206
+ data-cms-ui
207
+ >
208
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
209
+ <path
210
+ stroke-linecap="round"
211
+ stroke-linejoin="round"
212
+ d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
213
+ />
214
+ </svg>
215
+ </button>
216
+ )}
217
+
218
+ <button
219
+ type="button"
220
+ onClick={onClose}
221
+ class="p-1.5 text-white/40 hover:text-white hover:bg-white/10 rounded-cms-sm transition-colors shrink-0"
222
+ title="Cancel"
223
+ data-cms-ui
224
+ >
225
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
226
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
227
+ </svg>
228
+ </button>
229
+ </form>
230
+ </div>
231
+ )
232
+ }
@@ -4,10 +4,12 @@ import { slugify } from '../../shared'
4
4
  import { updateMarkdownPage } from '../api'
5
5
  import { STORAGE_KEYS, Z_INDEX } from '../constants'
6
6
  import { createMarkdownPage } from '../markdown-api'
7
+ import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
7
8
  import {
8
9
  config,
9
10
  currentMarkdownPage,
10
11
  isMarkdownPreview,
12
+ manifest,
11
13
  markdownEditorState,
12
14
  pendingCollectionEntries,
13
15
  resetMarkdownEditorState,
@@ -244,6 +246,41 @@ export function MarkdownEditorOverlay() {
244
246
  try {
245
247
  const view = editorInstanceRef.current.ctx.get(editorViewCtx)
246
248
  el.innerHTML = view.dom.innerHTML
249
+
250
+ // Replace MDX block cards with rendered component previews
251
+ el.querySelectorAll('.mdx-block-card-wrapper[data-mdx-component]').forEach((wrapper) => {
252
+ const name = wrapper.getAttribute('data-mdx-component')
253
+ if (!name) return
254
+ const def = manifest.value?.componentDefinitions?.[name]
255
+ if (!def?.file) return
256
+
257
+ const propsJson = wrapper.getAttribute('data-mdx-props') || '{}'
258
+ const childrenText = wrapper.getAttribute('data-mdx-children') || ''
259
+ let props: Record<string, string> = {}
260
+ try {
261
+ props = JSON.parse(propsJson)
262
+ } catch {}
263
+
264
+ // Filter out expression props
265
+ const staticProps: Record<string, string> = {}
266
+ for (const [k, v] of Object.entries(props)) {
267
+ if (!v.startsWith(MDX_EXPR_PREFIX)) staticProps[k] = v
268
+ }
269
+
270
+ const params = new URLSearchParams({ file: def.file, props: JSON.stringify(staticProps) })
271
+ if (childrenText) params.set('children', childrenText)
272
+
273
+ const iframe = document.createElement('iframe')
274
+ iframe.src = `/_nua/preview?${params}`
275
+ iframe.style.cssText = 'width:100%;border:0;display:block;min-height:60px'
276
+ iframe.onload = () => {
277
+ try {
278
+ const h = iframe.contentDocument?.body?.scrollHeight
279
+ if (h) iframe.style.height = `${h + 16}px`
280
+ } catch {}
281
+ }
282
+ wrapper.replaceWith(iframe)
283
+ })
247
284
  } catch (error) {
248
285
  console.error('Failed to get editor HTML for preview:', error)
249
286
  originalHTMLRef.current = null