@nuasite/cms 0.5.0 → 0.6.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.5.0",
17
+ "version": "0.6.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -4,7 +4,8 @@ import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { getProjectRoot } from './config'
7
- import { extractPropsFromSource, findComponentInvocationLine } from './handlers/component-ops'
7
+ import { detectArrayPattern, extractArrayElementProps } from './handlers/array-ops'
8
+ import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd } from './handlers/component-ops'
8
9
  import { extractComponentName, processHtml } from './html-processor'
9
10
  import type { ManifestWriter } from './manifest-writer'
10
11
  import { generateComponentPreviews } from './preview-generator'
@@ -640,6 +641,42 @@ async function processFile(
640
641
  comp.props = extractPropsFromSource(pageLines, invLine, comp.componentName)
641
642
  }
642
643
  }
644
+
645
+ // Resolve spread props for array-rendered components
646
+ const componentGroups = new Map<string, typeof result.components[string][]>()
647
+ for (const comp of Object.values(result.components)) {
648
+ const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
649
+ if (!componentGroups.has(key)) componentGroups.set(key, [])
650
+ componentGroups.get(key)!.push(comp)
651
+ }
652
+
653
+ for (const group of componentGroups.values()) {
654
+ if (group.length <= 1) continue
655
+ if (!group.some(c => Object.keys(c.props).length === 0)) continue
656
+
657
+ const firstComp = group[0]!
658
+ const invLine = findComponentInvocationLine(pageLines, firstComp.componentName, 0)
659
+ if (invLine < 0) continue
660
+
661
+ const pattern = detectArrayPattern(pageLines, invLine)
662
+ if (!pattern) continue
663
+
664
+ const fmEnd = findFrontmatterEnd(pageLines)
665
+ if (fmEnd === 0) continue
666
+
667
+ const frontmatterContent = pageLines.slice(1, fmEnd - 1).join('\n')
668
+
669
+ const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
670
+ for (let i = 0; i < sorted.length; i++) {
671
+ const comp = sorted[i]!
672
+ if (Object.keys(comp.props).length > 0) continue
673
+
674
+ const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
675
+ if (arrayProps) {
676
+ comp.props = arrayProps
677
+ }
678
+ }
679
+ }
643
680
  } catch {
644
681
  // Could not read page source — leave props empty
645
682
  }
@@ -3,10 +3,11 @@ import fs from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
5
5
  import { getProjectRoot } from './config'
6
- import { handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
6
+ import { detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
7
7
  import {
8
8
  extractPropsFromSource,
9
9
  findComponentInvocationLine,
10
+ findFrontmatterEnd,
10
11
  getPageFileCandidates,
11
12
  handleInsertComponent,
12
13
  handleRemoveComponent,
@@ -429,7 +430,7 @@ async function handleCmsApiRoute(
429
430
 
430
431
  // GET /_nua/cms/deployment/status
431
432
  if (route === 'deployment/status' && req.method === 'GET') {
432
- sendJson(res, { currentDeployment: null, pendingCount: 0 })
433
+ sendJson(res, { currentDeployment: null, pendingCount: 0, deploymentEnabled: false })
433
434
  return
434
435
  }
435
436
 
@@ -536,6 +537,50 @@ async function processHtmlForDev(
536
537
  }
537
538
  }
538
539
 
540
+ // Resolve spread props for array-rendered components.
541
+ // Group components by (name, invocationSourcePath) to detect array patterns.
542
+ const componentGroups = new Map<string, typeof result.components[string][]>()
543
+ for (const comp of Object.values(result.components)) {
544
+ const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
545
+ if (!componentGroups.has(key)) componentGroups.set(key, [])
546
+ componentGroups.get(key)!.push(comp)
547
+ }
548
+
549
+ for (const group of componentGroups.values()) {
550
+ if (group.length <= 1) continue
551
+ // Only process groups where at least one component has empty props (spread case)
552
+ if (!group.some(c => Object.keys(c.props).length === 0)) continue
553
+
554
+ const firstComp = group[0]!
555
+ const filePath = normalizeFilePath(firstComp.invocationSourcePath ?? firstComp.sourcePath)
556
+ const lines = await readLines(path.resolve(projectRoot, filePath))
557
+ if (!lines) continue
558
+
559
+ // Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
560
+ const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
561
+ if (invLine < 0) continue
562
+
563
+ const pattern = detectArrayPattern(lines, invLine)
564
+ if (!pattern) continue
565
+
566
+ const fmEnd = findFrontmatterEnd(lines)
567
+ if (fmEnd === 0) continue
568
+
569
+ const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
570
+
571
+ // Sort group by invocationIndex to match array element order
572
+ const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
573
+ for (let i = 0; i < sorted.length; i++) {
574
+ const comp = sorted[i]!
575
+ if (Object.keys(comp.props).length > 0) continue
576
+
577
+ const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
578
+ if (arrayProps) {
579
+ comp.props = arrayProps
580
+ }
581
+ }
582
+ }
583
+
539
584
  // Build collection entry if this is a collection page
540
585
  let collectionEntry: CollectionEntry | undefined
541
586
  if (collectionInfo && mdContent) {
@@ -3,6 +3,7 @@ import {
3
3
  closeCollectionsBrowser,
4
4
  isCollectionsBrowserOpen,
5
5
  manifest,
6
+ openMarkdownEditorForEntry,
6
7
  openMarkdownEditorForNewPage,
7
8
  selectBrowserCollection,
8
9
  selectedBrowserCollection,
@@ -16,7 +17,7 @@ export function CollectionsBrowser() {
16
17
  const collectionDefinitions = manifest.value.collectionDefinitions ?? {}
17
18
 
18
19
  const collections = useMemo(() => {
19
- return Object.values(collectionDefinitions)
20
+ return Object.values(collectionDefinitions).sort((a, b) => a.label.localeCompare(b.label))
20
21
  }, [collectionDefinitions])
21
22
 
22
23
  if (!visible) return null
@@ -38,11 +39,14 @@ export function CollectionsBrowser() {
38
39
 
39
40
  const handleEntryClick = (slug: string, sourcePath: string, pathname?: string) => {
40
41
  closeCollectionsBrowser()
41
- // Navigate to the collection detail page to edit inline.
42
- // Use known pathname or construct one from collection/slug.
43
- const targetPath = pathname || `/${selected}/${slug}`
44
- savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname: targetPath })
45
- window.location.href = targetPath
42
+ if (pathname) {
43
+ // Navigate to the collection detail page to edit inline.
44
+ savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname })
45
+ window.location.href = pathname
46
+ } else {
47
+ // No detail page exists for this entry — open the markdown editor inline.
48
+ openMarkdownEditorForEntry(selected, slug, sourcePath, def)
49
+ }
46
50
  }
47
51
 
48
52
  const handleAddNew = () => {
@@ -54,7 +54,7 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
54
54
  placeholder={placeholder}
55
55
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
56
56
  class={cn(
57
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
57
+ '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',
58
58
  isDirty
59
59
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
60
60
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -90,7 +90,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
90
90
  placeholder={placeholder}
91
91
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
92
92
  class={cn(
93
- 'flex-1 px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
93
+ 'flex-1 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',
94
94
  isDirty
95
95
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
96
96
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -100,7 +100,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
100
100
  <button
101
101
  type="button"
102
102
  onClick={onBrowse}
103
- class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-md text-sm text-white transition-colors cursor-pointer"
103
+ class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
104
104
  data-cms-ui
105
105
  >
106
106
  Browse
@@ -132,7 +132,7 @@ export function SelectField({ label, value, options, onChange, isDirty, onReset,
132
132
  value={value ?? ''}
133
133
  onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
134
134
  class={cn(
135
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
135
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
136
136
  isDirty
137
137
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
138
138
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -220,7 +220,7 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
220
220
  onChange(val === '' ? undefined : Number(val))
221
221
  }}
222
222
  class={cn(
223
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
223
+ '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',
224
224
  isDirty
225
225
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
226
226
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -341,7 +341,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
341
341
  onKeyDown={handleKeyDown}
342
342
  autocomplete="off"
343
343
  class={cn(
344
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
344
+ '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',
345
345
  isDirty
346
346
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
347
347
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -351,7 +351,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
351
351
  {showDropdown && (
352
352
  <div
353
353
  ref={listRef}
354
- class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-md shadow-lg"
354
+ class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
355
355
  data-cms-ui
356
356
  >
357
357
  {filtered.map((opt, i) => (
@@ -1,3 +1,4 @@
1
+ import { useState } from 'preact/hooks'
1
2
  import { markdownEditorState, openMediaLibraryWithCallback, updateMarkdownFrontmatter } from '../signals'
2
3
  import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
3
4
  import { ComboBoxField, ImageField, NumberField, TextField, ToggleField } from './fields'
@@ -87,6 +88,17 @@ export function FrontmatterField({
87
88
  )
88
89
  }
89
90
 
91
+ // Object field - render nested fields with add/remove
92
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
93
+ return (
94
+ <ObjectFields
95
+ label={label}
96
+ value={value as Record<string, unknown>}
97
+ onChange={onChange}
98
+ />
99
+ )
100
+ }
101
+
90
102
  // String field (default) - check if it's a long text (excerpt, etc.)
91
103
  const isLongText = fieldKey.toLowerCase().includes('excerpt')
92
104
  || fieldKey.toLowerCase().includes('description')
@@ -158,7 +170,7 @@ export function CreateModeFrontmatter({
158
170
  }
159
171
  }}
160
172
  placeholder="url-friendly-slug"
161
- class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
173
+ class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
162
174
  data-cms-ui
163
175
  />
164
176
  <p class="mt-1 text-xs text-white/40">
@@ -214,7 +226,7 @@ export function EditModeFrontmatter({
214
226
  <input
215
227
  type="text"
216
228
  value={page.slug}
217
- class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white/50 focus:outline-none cursor-not-allowed"
229
+ class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white/50 focus:outline-none cursor-not-allowed"
218
230
  disabled
219
231
  data-cms-ui
220
232
  />
@@ -415,6 +427,32 @@ export function SchemaFrontmatterField({
415
427
  )
416
428
  }
417
429
 
430
+ case 'object': {
431
+ const obj = (value ?? {}) as Record<string, unknown>
432
+ const nestedFields = field.fields ?? []
433
+ if (nestedFields.length > 0) {
434
+ // Schema-defined nested fields + any extra keys from the actual value
435
+ const schemaNames = new Set(nestedFields.map((f) => f.name))
436
+ const extraKeys = Object.keys(obj).filter((k) => !schemaNames.has(k))
437
+ return (
438
+ <ObjectFields
439
+ label={label}
440
+ value={obj}
441
+ onChange={onChange}
442
+ schemaFields={nestedFields}
443
+ extraKeys={extraKeys}
444
+ />
445
+ )
446
+ }
447
+ return (
448
+ <ObjectFields
449
+ label={label}
450
+ value={obj}
451
+ onChange={onChange}
452
+ />
453
+ )
454
+ }
455
+
418
456
  default:
419
457
  return (
420
458
  <div class="flex flex-col gap-1" data-cms-ui>
@@ -431,6 +469,129 @@ export function SchemaFrontmatterField({
431
469
  }
432
470
  }
433
471
 
472
+ // ============================================================================
473
+ // Object Fields — renders nested fields with add/remove key support
474
+ // ============================================================================
475
+
476
+ interface ObjectFieldsProps {
477
+ label: string
478
+ value: Record<string, unknown>
479
+ onChange: (value: unknown) => void
480
+ schemaFields?: FieldDefinition[]
481
+ extraKeys?: string[]
482
+ }
483
+
484
+ function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: ObjectFieldsProps) {
485
+ const [newKey, setNewKey] = useState('')
486
+ const obj = value ?? {}
487
+
488
+ const handleRemoveKey = (key: string) => {
489
+ const { [key]: _, ...rest } = obj
490
+ onChange(rest)
491
+ }
492
+
493
+ const handleAddKey = () => {
494
+ const trimmed = newKey.trim()
495
+ if (!trimmed || trimmed in obj) return
496
+ onChange({ ...obj, [trimmed]: '' })
497
+ setNewKey('')
498
+ }
499
+
500
+ return (
501
+ <div class="flex flex-col gap-2 col-span-2" data-cms-ui>
502
+ <label class="text-xs text-white/60 font-medium">{label}</label>
503
+ <div class="space-y-2 pl-3 border-l-2 border-white/10">
504
+ {schemaFields
505
+ ? (
506
+ <>
507
+ {schemaFields.map((subField) => (
508
+ <div key={subField.name} class="flex items-end gap-2">
509
+ <div class="flex-1 min-w-0">
510
+ <SchemaFrontmatterField
511
+ field={subField}
512
+ value={obj[subField.name]}
513
+ onChange={(newValue) => onChange({ ...obj, [subField.name]: newValue })}
514
+ />
515
+ </div>
516
+ </div>
517
+ ))}
518
+ {(extraKeys ?? []).map((key) => (
519
+ <div key={key} class="flex items-end gap-2">
520
+ <div class="flex-1 min-w-0">
521
+ <FrontmatterField
522
+ fieldKey={key}
523
+ value={obj[key]}
524
+ onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
525
+ />
526
+ </div>
527
+ <button
528
+ type="button"
529
+ onClick={() => handleRemoveKey(key)}
530
+ class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
531
+ title={`Remove ${key}`}
532
+ data-cms-ui
533
+ >
534
+ <RemoveIcon />
535
+ </button>
536
+ </div>
537
+ ))}
538
+ </>
539
+ )
540
+ : Object.entries(obj).map(([key, subValue]) => (
541
+ <div key={key} class="flex items-end gap-2">
542
+ <div class="flex-1 min-w-0">
543
+ <FrontmatterField
544
+ fieldKey={key}
545
+ value={subValue}
546
+ onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
547
+ />
548
+ </div>
549
+ <button
550
+ type="button"
551
+ onClick={() => handleRemoveKey(key)}
552
+ class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
553
+ title={`Remove ${key}`}
554
+ data-cms-ui
555
+ >
556
+ <RemoveIcon />
557
+ </button>
558
+ </div>
559
+ ))
560
+ }
561
+ {/* Add new key */}
562
+ <div class="flex items-center gap-2 pt-1">
563
+ <input
564
+ type="text"
565
+ value={newKey}
566
+ onInput={(e) => setNewKey((e.target as HTMLInputElement).value)}
567
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddKey() } }}
568
+ placeholder="New field name..."
569
+ class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
570
+ data-cms-ui
571
+ />
572
+ <button
573
+ type="button"
574
+ onClick={handleAddKey}
575
+ disabled={!newKey.trim() || newKey.trim() in obj}
576
+ class="px-2 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-sm transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
577
+ data-cms-ui
578
+ >
579
+ + Add
580
+ </button>
581
+ </div>
582
+ </div>
583
+ </div>
584
+ )
585
+ }
586
+
587
+ function RemoveIcon() {
588
+ return (
589
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
590
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
591
+ </svg>
592
+ )
593
+ }
594
+
434
595
  // ============================================================================
435
596
  // Helper Functions
436
597
  // ============================================================================
@@ -175,7 +175,8 @@ export function SeoEditor() {
175
175
  })
176
176
 
177
177
  if (result.errors && result.errors.length > 0) {
178
- showToast(`Saved ${result.updated} SEO changes, ${result.errors.length} failed`, 'error')
178
+ const details = result.errors.map(e => e.error).join('; ')
179
+ showToast(`SEO save failed: ${details}`, 'error')
179
180
  } else {
180
181
  showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
181
182
  clearPendingSeoChanges()
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'preact/hooks'
1
+ import { useCallback, useEffect, useState } from 'preact/hooks'
2
2
  import { TIMING } from '../../constants'
3
3
  import type { ToastMessage } from './types'
4
4
 
@@ -8,8 +8,16 @@ interface ToastProps extends ToastMessage {
8
8
 
9
9
  export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
10
10
  const [isVisible, setIsVisible] = useState(true)
11
+ const persistent = type === 'error'
12
+
13
+ const dismiss = useCallback(() => {
14
+ setIsVisible(false)
15
+ setTimeout(() => onRemove(id), TIMING.TOAST_FADE_DURATION_MS)
16
+ }, [id, onRemove])
11
17
 
12
18
  useEffect(() => {
19
+ if (persistent) return
20
+
13
21
  const hideTimer = setTimeout(() => {
14
22
  setIsVisible(false)
15
23
  }, TIMING.TOAST_VISIBLE_DURATION_MS)
@@ -22,7 +30,7 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
22
30
  clearTimeout(hideTimer)
23
31
  clearTimeout(removeTimer)
24
32
  }
25
- }, [id, onRemove])
33
+ }, [id, onRemove, persistent])
26
34
 
27
35
  const typeClasses = {
28
36
  error: 'bg-cms-dark border-l-4 border-l-cms-error text-white',
@@ -44,6 +52,15 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
44
52
  {type === 'error' && <span class="text-cms-error text-lg">✕</span>}
45
53
  {type === 'info' && <span class="w-2.5 h-2.5 rounded-full bg-white/50 shrink-0" />}
46
54
  {message}
55
+ {persistent && (
56
+ <button
57
+ onClick={dismiss}
58
+ class="ml-1 text-white/60 hover:text-white transition-colors text-lg leading-none cursor-pointer"
59
+ aria-label="Dismiss"
60
+ >
61
+
62
+ </button>
63
+ )}
47
64
  </div>
48
65
  )
49
66
  }
package/src/editor/dom.ts CHANGED
@@ -161,16 +161,19 @@ export function isStyledSpan(element: HTMLElement): boolean {
161
161
  }
162
162
 
163
163
  /**
164
- * Helper function to recursively extract plain text from child nodes,
165
- * replacing CMS elements with their placeholders.
166
- * Note: This returns plain text only - for styled content, use innerHTML directly.
164
+ * Block-level elements that browsers may create inside contentEditable on Enter.
165
+ * These are treated as line breaks when extracting text.
167
166
  */
167
+ const BLOCK_ELEMENTS = new Set(['div', 'p', 'section', 'article', 'header', 'footer', 'blockquote'])
168
+
168
169
  function extractTextFromChildNodes(parentNode: HTMLElement): string {
169
170
  let text = ''
170
171
 
171
172
  parentNode.childNodes.forEach(node => {
172
173
  if (node.nodeType === Node.TEXT_NODE) {
173
- text += node.nodeValue || ''
174
+ // Normalize non-breaking spaces (\u00a0) that browsers insert in
175
+ // contentEditable to regular spaces
176
+ text += (node.nodeValue || '').replace(/\u00a0/g, ' ')
174
177
  } else if (node.nodeType === Node.ELEMENT_NODE) {
175
178
  const element = node as HTMLElement
176
179
  const tagName = element.tagName.toLowerCase()
@@ -186,9 +189,17 @@ function extractTextFromChildNodes(parentNode: HTMLElement): string {
186
189
  if (directCmsId) {
187
190
  // Element has CMS ID - replace with placeholder
188
191
  text += `{{cms:${directCmsId}}}`
192
+ } else if (BLOCK_ELEMENTS.has(tagName)) {
193
+ // Block-level elements created by browser on Enter should be
194
+ // treated as line breaks, not collapsed into the text
195
+ const blockText = extractTextFromChildNodes(element)
196
+ if (blockText) {
197
+ // Only add <br> separator if there's already text before this block
198
+ text += (text ? '<br>' : '') + blockText
199
+ }
189
200
  } else {
190
201
  // For all other elements (including styled spans), just get their text content
191
- text += element.textContent || ''
202
+ text += (element.textContent || '').replace(/\u00a0/g, ' ')
192
203
  }
193
204
  }
194
205
  })
@@ -154,10 +154,14 @@ export async function startEditMode(
154
154
  makeElementEditable(el)
155
155
 
156
156
  // Suppress browser native contentEditable undo/redo (we handle it ourselves)
157
+ // Also prevent Enter/Shift+Enter from inserting line breaks
157
158
  el.addEventListener('beforeinput', (e) => {
158
159
  if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
159
160
  e.preventDefault()
160
161
  }
162
+ if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
163
+ e.preventDefault()
164
+ }
161
165
  })
162
166
 
163
167
  // Setup color tracking for elements with colorClasses in manifest
@@ -1122,11 +1126,24 @@ export interface DeploymentPollingOptions {
1122
1126
  * Start polling for deployment status after a save operation.
1123
1127
  * Polls the API every 3 seconds until deployment completes or fails.
1124
1128
  * Waits for deployment to appear for up to 30 seconds before giving up.
1129
+ * Skips polling entirely when deployment is not available (e.g. local dev).
1125
1130
  */
1126
- export function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): void {
1131
+ export async function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): Promise<void> {
1127
1132
  // Clear any existing timers
1128
1133
  stopDeploymentPolling()
1129
1134
 
1135
+ // Do a preflight check to see if deployment is available
1136
+ try {
1137
+ const preflight = await getDeploymentStatus(config.apiBase)
1138
+ if (preflight.deploymentEnabled === false) {
1139
+ // Deployment not available (e.g. local dev) — skip polling entirely
1140
+ return
1141
+ }
1142
+ } catch {
1143
+ // If we can't even reach the endpoint, skip polling
1144
+ return
1145
+ }
1146
+
1130
1147
  // Reset wait attempts counter and store the timestamp when we started
1131
1148
  deploymentWaitAttempts = 0
1132
1149
  deploymentStartTimestamp = new Date().toISOString()
@@ -142,10 +142,12 @@ const CmsUI = () => {
142
142
  if (result.success) {
143
143
  signals.showToast(`Saved ${result.updated} change(s) successfully!`, 'success')
144
144
  } else if (result.errors) {
145
- signals.showToast(`Saved ${result.updated}, ${result.errors.length} failed`, 'error')
145
+ const details = result.errors.map(e => e.error).join('; ')
146
+ signals.showToast(`Save failed: ${details}`, 'error')
146
147
  }
147
148
  } catch (err) {
148
- signals.showToast('Save failed see console', 'error')
149
+ const message = err instanceof Error ? err.message : 'Unknown error'
150
+ signals.showToast(`Save failed: ${message}`, 'error')
149
151
  }
150
152
  }, [config, updateUI])
151
153
 
@@ -418,6 +418,8 @@ export interface DeploymentStatusResponse {
418
418
  publishedUrl: string
419
419
  } | null
420
420
  pendingCount: number
421
+ /** When false, deployment is not available (e.g. local dev) and polling should be skipped */
422
+ deploymentEnabled?: boolean
421
423
  }
422
424
 
423
425
  export interface DeploymentState {