@nuasite/cms 0.8.2 → 0.9.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.8.2",
17
+ "version": "0.9.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -1,5 +1,14 @@
1
1
  import { render } from 'preact'
2
- import { useCallback, useEffect } from 'preact/hooks'
2
+ import { useCallback, useEffect, useRef } from 'preact/hooks'
3
+ import type { CmsElementDeselectedMessage, CmsElementSelectedMessage } from '../types'
4
+ import {
5
+ buildEditorState,
6
+ buildPageNavigatedMessage,
7
+ buildReadyMessage,
8
+ buildSelectedElement,
9
+ buildStateChangedMessage,
10
+ postToParent,
11
+ } from './post-message'
3
12
  import { fetchManifest } from './api'
4
13
  import { AIChat } from './components/ai-chat'
5
14
  import { AITooltip } from './components/ai-tooltip'
@@ -32,7 +41,7 @@ import {
32
41
  stopEditMode,
33
42
  toggleShowOriginal,
34
43
  } from './editor'
35
- import { performRedo, performUndo } from './history'
44
+ import { canRedo, canUndo, performRedo, performUndo } from './history'
36
45
  import {
37
46
  useAIHandlers,
38
47
  useBgImageHoverDetection,
@@ -103,6 +112,17 @@ const CmsUI = () => {
103
112
  }).catch(() => {})
104
113
  }, [])
105
114
 
115
+ // Re-fetch manifest on View Transitions navigation (astro:after-swap)
116
+ useEffect(() => {
117
+ const onNavigation = () => {
118
+ fetchManifest().then((manifest) => {
119
+ signals.setManifest(manifest)
120
+ }).catch(() => {})
121
+ }
122
+ document.addEventListener('astro:after-swap', onNavigation)
123
+ return () => document.removeEventListener('astro:after-swap', onNavigation)
124
+ }, [])
125
+
106
126
  // Auto-restore edit mode if it was active before a page refresh (e.g. after save triggers HMR)
107
127
  useEffect(() => {
108
128
  if (loadEditingState() && !signals.isEditing.value) {
@@ -117,6 +137,87 @@ const CmsUI = () => {
117
137
  }
118
138
  }, [])
119
139
 
140
+ // Send selected element info to parent window via postMessage (when inside an iframe)
141
+ const prevOutlineRef = useRef<{ cmsId: string | null; isComponent: boolean }>({ cmsId: null, isComponent: false })
142
+ useEffect(() => {
143
+ const prev = prevOutlineRef.current
144
+ const changed = outlineState.cmsId !== prev.cmsId
145
+ || outlineState.isComponent !== prev.isComponent
146
+ || (!outlineState.visible && (prev.cmsId !== null || prev.isComponent))
147
+
148
+ if (!changed) return
149
+ prevOutlineRef.current = { cmsId: outlineState.cmsId, isComponent: outlineState.isComponent }
150
+
151
+ if (outlineState.visible && (outlineState.cmsId || outlineState.isComponent)) {
152
+ const manifestData = signals.manifest.value
153
+ const entry = outlineState.cmsId ? manifestData.entries[outlineState.cmsId] : undefined
154
+ const componentEl = outlineState.element
155
+ const componentId = componentEl?.getAttribute('data-cms-component-id') ?? undefined
156
+ const instance = componentId ? manifestData.components?.[componentId] : undefined
157
+ const rect = outlineState.rect
158
+
159
+ const msg: CmsElementSelectedMessage = {
160
+ type: 'cms-element-selected',
161
+ element: buildSelectedElement({
162
+ cmsId: outlineState.cmsId,
163
+ isComponent: outlineState.isComponent,
164
+ componentName: outlineState.componentName,
165
+ componentId,
166
+ tagName: outlineState.tagName,
167
+ rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null,
168
+ entry,
169
+ instance,
170
+ }),
171
+ }
172
+ postToParent(msg)
173
+ } else {
174
+ const msg: CmsElementDeselectedMessage = { type: 'cms-element-deselected' }
175
+ postToParent(msg)
176
+ }
177
+ }, [outlineState])
178
+
179
+ // Send cms-ready + cms-page-navigated when manifest loads
180
+ const prevManifestRef = useRef<boolean>(false)
181
+ useEffect(() => {
182
+ const m = signals.manifest.value
183
+ // Only fire when manifest has entries (i.e. actually loaded)
184
+ if (Object.keys(m.entries).length === 0) return
185
+
186
+ if (!prevManifestRef.current) {
187
+ prevManifestRef.current = true
188
+ postToParent(buildReadyMessage(m, window.location.pathname))
189
+ } else {
190
+ postToParent(buildPageNavigatedMessage(m, window.location.pathname))
191
+ }
192
+ })
193
+
194
+ // Send cms-state-changed when editor state changes
195
+ const prevStateRef = useRef<string>('')
196
+ useEffect(() => {
197
+ const state = buildEditorState({
198
+ isEditing: signals.isEditing.value,
199
+ dirtyCount: {
200
+ text: signals.dirtyChangesCount.value,
201
+ image: signals.dirtyImageChangesCount.value,
202
+ color: signals.dirtyColorChangesCount.value,
203
+ bgImage: signals.dirtyBgImageChangesCount.value,
204
+ attribute: signals.dirtyAttributeChangesCount.value,
205
+ seo: signals.dirtySeoChangesCount.value,
206
+ total: signals.totalDirtyCount.value,
207
+ },
208
+ deploymentStatus: signals.deploymentStatus.value,
209
+ lastDeployedAt: signals.lastDeployedAt.value,
210
+ canUndo: canUndo.value,
211
+ canRedo: canRedo.value,
212
+ })
213
+
214
+ const key = JSON.stringify(state)
215
+ if (key === prevStateRef.current) return
216
+ prevStateRef.current = key
217
+
218
+ postToParent(buildStateChangedMessage(state))
219
+ })
220
+
120
221
  const {
121
222
  handleAIChatToggle,
122
223
  handleChatClose,
@@ -384,7 +485,6 @@ const CmsUI = () => {
384
485
  onCompare: handleCompare,
385
486
  onSave: handleSave,
386
487
  onDiscard: handleDiscard,
387
- onAIChat: handleAIChatToggle,
388
488
  onMediaLibrary: handleMediaLibrary,
389
489
  onDismissDeployment: handleDismissDeployment,
390
490
  onNavigateChange: () => {
@@ -400,18 +500,6 @@ const CmsUI = () => {
400
500
  />
401
501
  </ErrorBoundary>
402
502
 
403
- <ErrorBoundary componentName="AI Tooltip">
404
- <AITooltip
405
- callbacks={{
406
- onPromptSubmit: handleTooltipPromptSubmit,
407
- }}
408
- visible={!!tooltipState.elementId && isEditing && !isAIProcessing && !textSelectionState.hasSelection}
409
- elementId={tooltipState.elementId}
410
- rect={tooltipState.rect}
411
- processing={isAIProcessing}
412
- />
413
- </ErrorBoundary>
414
-
415
503
  <ErrorBoundary componentName="Text Style Toolbar">
416
504
  <TextStyleToolbar
417
505
  visible={textSelectionState.hasSelection && isEditing && !isAIProcessing && isTextStylingAllowed}
@@ -439,17 +527,6 @@ const CmsUI = () => {
439
527
  />
440
528
  </ErrorBoundary>
441
529
 
442
- <ErrorBoundary componentName="AI Chat">
443
- <AIChat
444
- callbacks={{
445
- onSend: handleChatSend,
446
- onClose: handleChatClose,
447
- onCancel: handleChatCancel,
448
- onApplyToElement: handleApplyToElement,
449
- }}
450
- />
451
- </ErrorBoundary>
452
-
453
530
  <ErrorBoundary componentName="Block Editor">
454
531
  <BlockEditor
455
532
  visible={blockEditorState.isOpen && isEditing}
@@ -0,0 +1,136 @@
1
+ import type {
2
+ CmsEditorState,
3
+ CmsManifest,
4
+ CmsPageNavigatedMessage,
5
+ CmsPostMessage,
6
+ CmsReadyMessage,
7
+ CmsSelectedElement,
8
+ CmsStateChangedMessage,
9
+ ManifestEntry,
10
+ PageSeoData,
11
+ } from '../types'
12
+ import type { ComponentInstance } from '../types'
13
+
14
+ /** Send a postMessage to the parent window (no-op when not in an iframe) */
15
+ export function postToParent(msg: CmsPostMessage): void {
16
+ if (window.parent !== window) {
17
+ window.parent.postMessage(msg, '*')
18
+ }
19
+ }
20
+
21
+ /** Build a CmsSelectedElement from manifest data and outline state */
22
+ export function buildSelectedElement(opts: {
23
+ cmsId: string | null
24
+ isComponent: boolean
25
+ componentName?: string
26
+ componentId?: string
27
+ tagName?: string
28
+ rect: { x: number; y: number; width: number; height: number } | null
29
+ entry?: ManifestEntry
30
+ instance?: ComponentInstance
31
+ }): CmsSelectedElement {
32
+ const { cmsId, isComponent, componentName, componentId, tagName, rect, entry, instance } = opts
33
+ return {
34
+ cmsId,
35
+ isComponent,
36
+ componentName: componentName ?? instance?.componentName,
37
+ componentId,
38
+ tagName: tagName ?? entry?.tag,
39
+ rect,
40
+ ...(entry && {
41
+ text: entry.text,
42
+ html: entry.html,
43
+ sourcePath: entry.sourcePath,
44
+ sourceLine: entry.sourceLine,
45
+ sourceSnippet: entry.sourceSnippet,
46
+ sourceHash: entry.sourceHash,
47
+ stableId: entry.stableId,
48
+ contentPath: entry.contentPath,
49
+ parentComponentId: entry.parentComponentId,
50
+ childCmsIds: entry.childCmsIds,
51
+ imageMetadata: entry.imageMetadata,
52
+ backgroundImage: entry.backgroundImage,
53
+ colorClasses: entry.colorClasses,
54
+ attributes: entry.attributes,
55
+ constraints: entry.constraints,
56
+ allowStyling: entry.allowStyling,
57
+ collectionName: entry.collectionName,
58
+ collectionSlug: entry.collectionSlug,
59
+ }),
60
+ ...(instance && {
61
+ component: {
62
+ name: instance.componentName,
63
+ file: instance.file,
64
+ sourcePath: instance.sourcePath,
65
+ sourceLine: instance.sourceLine,
66
+ props: instance.props,
67
+ slots: instance.slots,
68
+ },
69
+ }),
70
+ }
71
+ }
72
+
73
+ /** Build a CmsReadyMessage from the loaded manifest */
74
+ export function buildReadyMessage(manifest: CmsManifest, pathname: string): CmsReadyMessage {
75
+ const seo = (manifest as any).seo as PageSeoData | undefined
76
+ const pageTitle = seo?.title?.content ?? manifest.pages?.find(p => p.pathname === pathname)?.title
77
+
78
+ return {
79
+ type: 'cms-ready',
80
+ data: {
81
+ pathname,
82
+ pageTitle,
83
+ seo,
84
+ pages: manifest.pages,
85
+ collectionDefinitions: manifest.collectionDefinitions,
86
+ componentDefinitions: manifest.componentDefinitions,
87
+ availableColors: manifest.availableColors,
88
+ availableTextStyles: manifest.availableTextStyles,
89
+ metadata: manifest.metadata,
90
+ },
91
+ }
92
+ }
93
+
94
+ /** Build a CmsPageNavigatedMessage */
95
+ export function buildPageNavigatedMessage(manifest: CmsManifest, pathname: string): CmsPageNavigatedMessage {
96
+ const seo = (manifest as any).seo as PageSeoData | undefined
97
+ const pageTitle = seo?.title?.content ?? manifest.pages?.find(p => p.pathname === pathname)?.title
98
+
99
+ return {
100
+ type: 'cms-page-navigated',
101
+ page: {
102
+ pathname,
103
+ title: pageTitle,
104
+ },
105
+ }
106
+ }
107
+
108
+ /** Build a CmsEditorState snapshot from current signal values */
109
+ export function buildEditorState(opts: {
110
+ isEditing: boolean
111
+ dirtyCount: CmsEditorState['dirtyCount']
112
+ deploymentStatus: CmsEditorState['deployment']['status']
113
+ lastDeployedAt: string | null
114
+ canUndo: boolean
115
+ canRedo: boolean
116
+ }): CmsEditorState {
117
+ return {
118
+ isEditing: opts.isEditing,
119
+ hasChanges: opts.dirtyCount.total > 0,
120
+ dirtyCount: opts.dirtyCount,
121
+ deployment: {
122
+ status: opts.deploymentStatus,
123
+ lastDeployedAt: opts.lastDeployedAt,
124
+ },
125
+ canUndo: opts.canUndo,
126
+ canRedo: opts.canRedo,
127
+ }
128
+ }
129
+
130
+ /** Build a CmsStateChangedMessage */
131
+ export function buildStateChangedMessage(state: CmsEditorState): CmsStateChangedMessage {
132
+ return {
133
+ type: 'cms-state-changed',
134
+ state,
135
+ }
136
+ }
@@ -501,17 +501,7 @@ export function applyTextChange(
501
501
  ): { success: true; content: string } | { success: false; error: string } {
502
502
  const { sourceSnippet, originalValue, newValue, htmlValue } = change
503
503
 
504
- let newText = htmlValue ?? newValue
505
- newText = resolveCmsPlaceholders(newText, manifest)
506
-
507
- // Resolve CMS placeholders in originalValue too — when a parent element has
508
- // child CMS elements, originalValue contains {{cms:cms-N}} placeholders but
509
- // the sourceSnippet contains the actual HTML for those children.
510
- const resolvedOriginal = originalValue
511
- ? resolveCmsPlaceholders(originalValue, manifest)
512
- : originalValue
513
-
514
- if (!sourceSnippet || !resolvedOriginal) {
504
+ if (!sourceSnippet || !originalValue) {
515
505
  if (change.attributeChanges && change.attributeChanges.length > 0) {
516
506
  return { success: true, content }
517
507
  }
@@ -522,14 +512,30 @@ export function applyTextChange(
522
512
  return { success: false, error: 'Source snippet not found in file' }
523
513
  }
524
514
 
525
- // Replace resolvedOriginal with newText WITHIN the sourceSnippet
526
- const updatedSnippet = sourceSnippet.replace(resolvedOriginal, newText)
515
+ const newText = htmlValue ?? newValue
516
+
517
+ // When originalValue contains CMS placeholders (child elements like {{cms:cms-5}}),
518
+ // replace only the text segments between placeholders directly in the sourceSnippet.
519
+ // This avoids resolving placeholders via child sourceSnippets, which can be incorrect
520
+ // when multiple inline children share the same source line (extractCompleteTagSnippet
521
+ // returns the entire line, not just the individual child tag).
522
+ const placeholderPattern = /\{\{cms:[^}]+\}\}/g
523
+ if (placeholderPattern.test(originalValue)) {
524
+ return applyTextChangeWithPlaceholders(content, sourceSnippet, originalValue, newText)
525
+ }
526
+
527
+ // No placeholders — resolve and match directly
528
+ const resolvedNewText = resolveCmsPlaceholders(newText, manifest)
529
+ const resolvedOriginal = resolveCmsPlaceholders(originalValue, manifest)
530
+
531
+ // Replace resolvedOriginal with resolvedNewText WITHIN the sourceSnippet
532
+ const updatedSnippet = sourceSnippet.replace(resolvedOriginal, resolvedNewText)
527
533
 
528
534
  if (updatedSnippet === sourceSnippet) {
529
535
  // resolvedOriginal wasn't found in snippet - try HTML entity handling
530
536
  const matchedText = findTextInSnippet(sourceSnippet, resolvedOriginal)
531
537
  if (matchedText) {
532
- const updatedWithEntity = sourceSnippet.replace(matchedText, newText)
538
+ const updatedWithEntity = sourceSnippet.replace(matchedText, resolvedNewText)
533
539
  return { success: true, content: content.replace(sourceSnippet, updatedWithEntity) }
534
540
  }
535
541
  // Try inner content replacement for text spanning inline HTML elements
@@ -539,7 +545,7 @@ export function applyTextChange(
539
545
  const [, openTag, , innerContent, closeTag] = innerMatch
540
546
  const textOnly = innerContent!.replace(/<[^>]+>/g, '')
541
547
  if (textOnly === resolvedOriginal) {
542
- return { success: true, content: content.replace(sourceSnippet, openTag + newText + closeTag) }
548
+ return { success: true, content: content.replace(sourceSnippet, openTag + resolvedNewText + closeTag) }
543
549
  }
544
550
  }
545
551
 
@@ -552,6 +558,60 @@ export function applyTextChange(
552
558
  return { success: true, content: content.replace(sourceSnippet, updatedSnippet) }
553
559
  }
554
560
 
561
+ /**
562
+ * Apply text change when originalValue contains CMS placeholders.
563
+ * Splits by placeholder boundaries and replaces only the changed text segments.
564
+ */
565
+ function applyTextChangeWithPlaceholders(
566
+ content: string,
567
+ sourceSnippet: string,
568
+ originalValue: string,
569
+ newText: string,
570
+ ): { success: true; content: string } | { success: false; error: string } {
571
+ const placeholderPattern = /\{\{cms:[^}]+\}\}/g
572
+
573
+ const originalParts = originalValue.split(placeholderPattern)
574
+ const newParts = newText.split(placeholderPattern)
575
+
576
+ if (originalParts.length !== newParts.length) {
577
+ return { success: false, error: 'Placeholder structure mismatch between original and new values' }
578
+ }
579
+
580
+ let updatedSnippet = sourceSnippet
581
+ let anyChange = false
582
+
583
+ for (let i = 0; i < originalParts.length; i++) {
584
+ const oldPart = originalParts[i]!
585
+ let newPart = newParts[i]!
586
+
587
+ if (oldPart === newPart || oldPart.length === 0) {
588
+ continue
589
+ }
590
+
591
+ // Try direct match first, then entity-aware match
592
+ const matchedText = findTextInSnippet(updatedSnippet, oldPart)
593
+ if (matchedText) {
594
+ // When entity-aware matching was needed, encode the same entities in the replacement
595
+ if (matchedText !== oldPart) {
596
+ newPart = encodeEntitiesLike(newPart, matchedText)
597
+ }
598
+ updatedSnippet = updatedSnippet.replace(matchedText, newPart)
599
+ anyChange = true
600
+ } else {
601
+ return {
602
+ success: false,
603
+ error: `Text segment "${oldPart.substring(0, 50)}..." not found in source snippet`,
604
+ }
605
+ }
606
+ }
607
+
608
+ if (!anyChange) {
609
+ return { success: false, error: 'No text changes detected between original and new values' }
610
+ }
611
+
612
+ return { success: true, content: content.replace(sourceSnippet, updatedSnippet) }
613
+ }
614
+
555
615
  /**
556
616
  * Find the original text within a source snippet, accounting for HTML entities.
557
617
  */
@@ -592,6 +652,32 @@ function findTextInSnippet(snippet: string, decodedText: string): string | null
592
652
  return brMatch && brMatch[0] !== decodedText ? brMatch[0] : null
593
653
  }
594
654
 
655
+ /**
656
+ * Encode HTML entities in text to match the encoding used in a reference string.
657
+ * When entity-aware matching found entities in the source, the replacement text
658
+ * needs the same encoding to preserve valid HTML.
659
+ */
660
+ function encodeEntitiesLike(text: string, reference: string): string {
661
+ let result = text
662
+ // & must be encoded first to avoid double-encoding other entities
663
+ if (reference.includes('&amp;')) {
664
+ result = result.replace(/&/g, '&amp;')
665
+ }
666
+ if (reference.includes('&lt;')) {
667
+ result = result.replace(/</g, '&lt;')
668
+ }
669
+ if (reference.includes('&gt;')) {
670
+ result = result.replace(/>/g, '&gt;')
671
+ }
672
+ if (reference.includes('&quot;')) {
673
+ result = result.replace(/"/g, '&quot;')
674
+ }
675
+ if (reference.includes('&#39;') || reference.includes('&apos;')) {
676
+ result = result.replace(/'/g, '&#39;')
677
+ }
678
+ return result
679
+ }
680
+
595
681
  /**
596
682
  * Resolve CMS placeholders like {{cms:cms-96}} in text.
597
683
  */
package/src/index.ts CHANGED
@@ -287,8 +287,17 @@ export type {
287
287
  AvailableColors,
288
288
  AvailableTextStyles,
289
289
  CanonicalUrl,
290
+ CmsEditorState,
291
+ CmsElementDeselectedMessage,
292
+ CmsElementSelectedMessage,
290
293
  CmsManifest,
291
294
  CmsMarkerOptions,
295
+ CmsPageNavigatedMessage,
296
+ CmsPostMessage,
297
+ CmsReadyData,
298
+ CmsReadyMessage,
299
+ CmsSelectedElement,
300
+ CmsStateChangedMessage,
292
301
  CollectionDefinition,
293
302
  CollectionEntry,
294
303
  ComponentDefinition,
package/src/types.ts CHANGED
@@ -423,3 +423,148 @@ export interface PageSeoData {
423
423
  /** JSON-LD structured data blocks */
424
424
  jsonLd?: JsonLdEntry[]
425
425
  }
426
+
427
+ // ============================================================================
428
+ // PostMessage Types (iframe communication)
429
+ // ============================================================================
430
+
431
+ /** Element data sent to parent when a CMS element is hovered/selected */
432
+ export interface CmsSelectedElement {
433
+ /** CMS element ID (null for component-only selections) */
434
+ cmsId: string | null
435
+ /** Whether the selected element is a component root */
436
+ isComponent: boolean
437
+ /** Component name if applicable */
438
+ componentName?: string
439
+ /** Component instance ID */
440
+ componentId?: string
441
+ /** HTML tag name */
442
+ tagName?: string
443
+ /** Bounding rect relative to the iframe viewport */
444
+ rect: { x: number; y: number; width: number; height: number } | null
445
+
446
+ // --- Manifest entry data (text/image elements) ---
447
+
448
+ /** Plain text content */
449
+ text?: string
450
+ /** HTML content with inline styling */
451
+ html?: string
452
+ /** Source file path */
453
+ sourcePath?: string
454
+ /** Line number in source file */
455
+ sourceLine?: number
456
+ /** Parent component ID */
457
+ parentComponentId?: string
458
+ /** Nested CMS element IDs */
459
+ childCmsIds?: string[]
460
+ /** Image metadata for img elements */
461
+ imageMetadata?: ImageMetadata
462
+ /** Background image metadata */
463
+ backgroundImage?: BackgroundImageMetadata
464
+ /** Color classes (bg, text, border, etc.) */
465
+ colorClasses?: Record<string, Attribute>
466
+ /** HTML attributes with source info */
467
+ attributes?: Record<string, Attribute>
468
+ /** Content validation constraints */
469
+ constraints?: ContentConstraints
470
+ /** Whether inline text styling is allowed */
471
+ allowStyling?: boolean
472
+ /** Collection name if from a content collection */
473
+ collectionName?: string
474
+ /** Collection entry slug */
475
+ collectionSlug?: string
476
+ /** Full element snippet from source */
477
+ sourceSnippet?: string
478
+ /** SHA256 hash of sourceSnippet for conflict detection */
479
+ sourceHash?: string
480
+ /** Stable ID derived from content + context hash */
481
+ stableId?: string
482
+ /** Path to the markdown content file */
483
+ contentPath?: string
484
+
485
+ // --- Component instance data ---
486
+
487
+ /** Full component instance info (when isComponent is true) */
488
+ component?: {
489
+ name: string
490
+ file: string
491
+ sourcePath: string
492
+ sourceLine: number
493
+ props: Record<string, unknown>
494
+ slots?: Record<string, string>
495
+ }
496
+ }
497
+
498
+ /** Message sent when a CMS element is hovered/selected */
499
+ export interface CmsElementSelectedMessage {
500
+ type: 'cms-element-selected'
501
+ element: CmsSelectedElement
502
+ }
503
+
504
+ /** Message sent when no element is hovered */
505
+ export interface CmsElementDeselectedMessage {
506
+ type: 'cms-element-deselected'
507
+ }
508
+
509
+ /** Data sent with the cms-ready message when the manifest first loads */
510
+ export interface CmsReadyData {
511
+ pathname: string
512
+ pageTitle?: string
513
+ seo?: PageSeoData
514
+ pages?: PageEntry[]
515
+ collectionDefinitions?: Record<string, CollectionDefinition>
516
+ componentDefinitions?: Record<string, ComponentDefinition>
517
+ availableColors?: AvailableColors
518
+ availableTextStyles?: AvailableTextStyles
519
+ metadata?: ManifestMetadata
520
+ }
521
+
522
+ /** Message sent when the CMS manifest has loaded and the editor is ready */
523
+ export interface CmsReadyMessage {
524
+ type: 'cms-ready'
525
+ data: CmsReadyData
526
+ }
527
+
528
+ /** Snapshot of editor state sent on every meaningful change */
529
+ export interface CmsEditorState {
530
+ isEditing: boolean
531
+ hasChanges: boolean
532
+ dirtyCount: {
533
+ text: number
534
+ image: number
535
+ color: number
536
+ bgImage: number
537
+ attribute: number
538
+ seo: number
539
+ total: number
540
+ }
541
+ deployment: {
542
+ status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' | null
543
+ lastDeployedAt: string | null
544
+ }
545
+ canUndo: boolean
546
+ canRedo: boolean
547
+ }
548
+
549
+ /** Message sent when editor state changes (dirty counts, deployment, editing mode, undo/redo) */
550
+ export interface CmsStateChangedMessage {
551
+ type: 'cms-state-changed'
552
+ state: CmsEditorState
553
+ }
554
+
555
+ /** Message sent when the user navigates to a different page (manifest reload) */
556
+ export interface CmsPageNavigatedMessage {
557
+ type: 'cms-page-navigated'
558
+ page: {
559
+ pathname: string
560
+ title?: string
561
+ }
562
+ }
563
+
564
+ /** All possible CMS postMessage types sent from the editor iframe to the parent */
565
+ export type CmsPostMessage =
566
+ | CmsElementSelectedMessage
567
+ | CmsElementDeselectedMessage
568
+ | CmsReadyMessage
569
+ | CmsStateChangedMessage
570
+ | CmsPageNavigatedMessage