@nuasite/cms 0.8.3 → 0.9.1

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.3",
17
+ "version": "0.9.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -10,7 +10,7 @@ export interface ToolbarCallbacks {
10
10
  onCompare: () => void
11
11
  onSave: () => void
12
12
  onDiscard: () => void
13
- onAIChat?: () => void
13
+ onSelectElement?: () => void
14
14
  onMediaLibrary?: () => void
15
15
  onDismissDeployment?: () => void
16
16
  onNavigateChange?: () => void
@@ -97,7 +97,6 @@ const DeploymentStatusIndicator = ({ onDismiss }: { onDismiss?: () => void }) =>
97
97
  export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
98
98
  const isEditing = signals.isEditing.value
99
99
  const showingOriginal = signals.showingOriginal.value
100
- const isChatOpen = signals.isChatOpen.value
101
100
  const dirtyCount = signals.totalDirtyCount.value
102
101
  const isSaving = signals.isSaving.value
103
102
  const deploymentStatus = signals.deploymentStatus.value
@@ -107,7 +106,6 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
107
106
  const [isMenuOpen, setIsMenuOpen] = useState(false)
108
107
 
109
108
  if (isPreviewingMarkdown) return null
110
- if (isChatOpen && !isEditing) return null
111
109
 
112
110
  const showDeploymentStatus = deploymentStatus !== null
113
111
 
@@ -126,21 +124,23 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
126
124
  }
127
125
  }
128
126
 
129
- const isToolbarOpen = isEditing
127
+ const isSelectMode = signals.isSelectMode.value
128
+ const isToolbarOpen = isEditing || isSelectMode
130
129
 
131
130
  // Build menu items dynamically
132
131
  const menuItems: Array<{ label: string; icon: ComponentChildren; onClick: () => void; isActive?: boolean }> = []
133
132
 
134
- if (callbacks.onAIChat) {
133
+ if (callbacks.onSelectElement) {
135
134
  menuItems.push({
136
- label: 'AI Chat',
135
+ label: 'Select Element',
137
136
  icon: (
138
137
  <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
139
- <path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z" />
138
+ <path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
139
+ <path d="M13 13l6 6" />
140
140
  </svg>
141
141
  ),
142
- onClick: () => callbacks.onAIChat?.(),
143
- isActive: isChatOpen,
142
+ onClick: () => callbacks.onSelectElement?.(),
143
+ isActive: isSelectMode,
144
144
  })
145
145
  }
146
146
 
@@ -58,9 +58,9 @@ export function useElementDetection(): OutlineState {
58
58
  useEffect(() => {
59
59
  const handleMouseMove = (ev: MouseEvent) => {
60
60
  const isEditing = signals.isEditing.value
61
- const chatOpen = signals.isChatOpen.value
61
+ const selectMode = signals.isSelectMode.value
62
62
 
63
- if (!isEditing && !chatOpen) {
63
+ if (!isEditing && !selectMode) {
64
64
  if (hideTimeoutRef.current) {
65
65
  clearTimeout(hideTimeoutRef.current)
66
66
  hideTimeoutRef.current = null
@@ -89,34 +89,6 @@ export function useElementDetection(): OutlineState {
89
89
  const manifest = signals.manifest.value
90
90
  const entries = manifest.entries
91
91
 
92
- // When chat is open, only detect components (not text/image elements)
93
- if (chatOpen) {
94
- const componentEl = getComponentAtPosition(ev.clientX, ev.clientY)
95
- if (componentEl) {
96
- if (hideTimeoutRef.current) {
97
- clearTimeout(hideTimeoutRef.current)
98
- hideTimeoutRef.current = null
99
- }
100
- const rect = componentEl.getBoundingClientRect()
101
- const componentId = componentEl.getAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
102
- const instance = componentId ? getComponentInstance(manifest, componentId) : null
103
-
104
- setOutlineState({
105
- visible: true,
106
- rect,
107
- isComponent: true,
108
- componentName: instance?.componentName,
109
- tagName: componentEl.tagName.toLowerCase(),
110
- element: componentEl,
111
- cmsId: null,
112
- })
113
- return
114
- }
115
-
116
- setOutlineState(INITIAL_STATE)
117
- return
118
- }
119
-
120
92
  // Use the improved elementsFromPoint-based detection
121
93
  const cmsEl = getCmsElementAtPosition(ev.clientX, ev.clientY, entries)
122
94
 
@@ -222,8 +194,8 @@ export function useComponentClickHandler({
222
194
  useEffect(() => {
223
195
  const handleClick = (ev: MouseEvent) => {
224
196
  const isEditing = signals.isEditing.value
225
- const chatOpen = signals.isChatOpen.value
226
- if (!isEditing && !chatOpen) return
197
+ const selectMode = signals.isSelectMode.value
198
+ if (!isEditing && !selectMode) return
227
199
 
228
200
  // Ignore clicks on CMS UI elements
229
201
  if (isEventOnCmsUI(ev)) return
@@ -231,20 +203,6 @@ export function useComponentClickHandler({
231
203
  const manifest = signals.manifest.value
232
204
  const entries = manifest.entries
233
205
 
234
- if (chatOpen) {
235
- // When chat is open, only select components
236
- const componentEl = getComponentAtPosition(ev.clientX, ev.clientY)
237
- if (componentEl) {
238
- const componentId = componentEl.getAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
239
- if (componentId) {
240
- ev.preventDefault()
241
- ev.stopPropagation()
242
- signals.setChatContextElement(componentId)
243
- }
244
- }
245
- return
246
- }
247
-
248
206
  // Normal editing mode behavior
249
207
  // Check for text element first
250
208
  const textEl = getCmsElementAtPosition(ev.clientX, ev.clientY, entries)
@@ -1,8 +1,7 @@
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'
3
4
  import { fetchManifest } from './api'
4
- import { AIChat } from './components/ai-chat'
5
- import { AITooltip } from './components/ai-tooltip'
6
5
  import { AttributeEditor } from './components/attribute-editor'
7
6
  import { BgImageOverlay } from './components/bg-image-overlay'
8
7
  import { BlockEditor } from './components/block-editor'
@@ -32,9 +31,8 @@ import {
32
31
  stopEditMode,
33
32
  toggleShowOriginal,
34
33
  } from './editor'
35
- import { performRedo, performUndo } from './history'
34
+ import { canRedo, canUndo, performRedo, performUndo } from './history'
36
35
  import {
37
- useAIHandlers,
38
36
  useBgImageHoverDetection,
39
37
  useBlockEditorHandlers,
40
38
  useComponentClickHandler,
@@ -43,6 +41,14 @@ import {
43
41
  useTextSelection,
44
42
  useTooltipState,
45
43
  } from './hooks'
44
+ import {
45
+ buildEditorState,
46
+ buildPageNavigatedMessage,
47
+ buildReadyMessage,
48
+ buildSelectedElement,
49
+ buildStateChangedMessage,
50
+ postToParent,
51
+ } from './post-message'
46
52
  import {
47
53
  openCollectionsBrowser,
48
54
  openMarkdownEditorForCurrentPage,
@@ -103,6 +109,17 @@ const CmsUI = () => {
103
109
  }).catch(() => {})
104
110
  }, [])
105
111
 
112
+ // Re-fetch manifest on View Transitions navigation (astro:after-swap)
113
+ useEffect(() => {
114
+ const onNavigation = () => {
115
+ fetchManifest().then((manifest) => {
116
+ signals.setManifest(manifest)
117
+ }).catch(() => {})
118
+ }
119
+ document.addEventListener('astro:after-swap', onNavigation)
120
+ return () => document.removeEventListener('astro:after-swap', onNavigation)
121
+ }, [])
122
+
106
123
  // Auto-restore edit mode if it was active before a page refresh (e.g. after save triggers HMR)
107
124
  useEffect(() => {
108
125
  if (loadEditingState() && !signals.isEditing.value) {
@@ -117,18 +134,85 @@ const CmsUI = () => {
117
134
  }
118
135
  }, [])
119
136
 
120
- const {
121
- handleAIChatToggle,
122
- handleChatClose,
123
- handleChatCancel,
124
- handleTooltipPromptSubmit,
125
- handleChatSend,
126
- handleApplyToElement,
127
- } = useAIHandlers({
128
- config,
129
- showToast: signals.showToast,
130
- onTooltipHide: hideTooltip,
131
- onUIUpdate: updateUI,
137
+ // Send selected element info to parent window via postMessage (when inside an iframe)
138
+ const prevOutlineRef = useRef<{ cmsId: string | null; isComponent: boolean }>({ cmsId: null, isComponent: false })
139
+ useEffect(() => {
140
+ const prev = prevOutlineRef.current
141
+ const changed = outlineState.cmsId !== prev.cmsId
142
+ || outlineState.isComponent !== prev.isComponent
143
+ || (!outlineState.visible && (prev.cmsId !== null || prev.isComponent))
144
+
145
+ if (!changed) return
146
+ prevOutlineRef.current = { cmsId: outlineState.cmsId, isComponent: outlineState.isComponent }
147
+
148
+ if (outlineState.visible && (outlineState.cmsId || outlineState.isComponent)) {
149
+ const manifestData = signals.manifest.value
150
+ const entry = outlineState.cmsId ? manifestData.entries[outlineState.cmsId] : undefined
151
+ const componentEl = outlineState.element
152
+ const componentId = componentEl?.getAttribute('data-cms-component-id') ?? undefined
153
+ const instance = componentId ? manifestData.components?.[componentId] : undefined
154
+ const rect = outlineState.rect
155
+
156
+ const msg: CmsElementSelectedMessage = {
157
+ type: 'cms-element-selected',
158
+ element: buildSelectedElement({
159
+ cmsId: outlineState.cmsId,
160
+ isComponent: outlineState.isComponent,
161
+ componentName: outlineState.componentName,
162
+ componentId,
163
+ tagName: outlineState.tagName,
164
+ rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null,
165
+ entry,
166
+ instance,
167
+ }),
168
+ }
169
+ postToParent(msg)
170
+ } else {
171
+ const msg: CmsElementDeselectedMessage = { type: 'cms-element-deselected' }
172
+ postToParent(msg)
173
+ }
174
+ }, [outlineState])
175
+
176
+ // Send cms-ready + cms-page-navigated when manifest loads
177
+ const prevManifestRef = useRef<boolean>(false)
178
+ useEffect(() => {
179
+ const m = signals.manifest.value
180
+ // Only fire when manifest has entries (i.e. actually loaded)
181
+ if (Object.keys(m.entries).length === 0) return
182
+
183
+ if (!prevManifestRef.current) {
184
+ prevManifestRef.current = true
185
+ postToParent(buildReadyMessage(m, window.location.pathname))
186
+ } else {
187
+ postToParent(buildPageNavigatedMessage(m, window.location.pathname))
188
+ }
189
+ })
190
+
191
+ // Send cms-state-changed when editor state changes
192
+ const prevStateRef = useRef<string>('')
193
+ useEffect(() => {
194
+ const state = buildEditorState({
195
+ isEditing: signals.isEditing.value,
196
+ dirtyCount: {
197
+ text: signals.dirtyChangesCount.value,
198
+ image: signals.dirtyImageChangesCount.value,
199
+ color: signals.dirtyColorChangesCount.value,
200
+ bgImage: signals.dirtyBgImageChangesCount.value,
201
+ attribute: signals.dirtyAttributeChangesCount.value,
202
+ seo: signals.dirtySeoChangesCount.value,
203
+ total: signals.totalDirtyCount.value,
204
+ },
205
+ deploymentStatus: signals.deploymentStatus.value,
206
+ lastDeployedAt: signals.lastDeployedAt.value,
207
+ canUndo: canUndo.value,
208
+ canRedo: canRedo.value,
209
+ })
210
+
211
+ const key = JSON.stringify(state)
212
+ if (key === prevStateRef.current) return
213
+ prevStateRef.current = key
214
+
215
+ postToParent(buildStateChangedMessage(state))
132
216
  })
133
217
 
134
218
  const {
@@ -151,6 +235,7 @@ const CmsUI = () => {
151
235
  hideTooltip()
152
236
  stopEditMode(updateUI)
153
237
  } else {
238
+ signals.isSelectMode.value = false
154
239
  await startEditMode(config, updateUI)
155
240
  }
156
241
  }, [config, updateUI, hideTooltip])
@@ -209,6 +294,10 @@ const CmsUI = () => {
209
294
  openSeoEditor()
210
295
  }, [])
211
296
 
297
+ const handleSelectElementToggle = useCallback(() => {
298
+ signals.isSelectMode.value = !signals.isSelectMode.value
299
+ }, [])
300
+
212
301
  // Color toolbar handlers
213
302
  const handleColorToolbarChange = useCallback(
214
303
  (
@@ -308,7 +397,6 @@ const CmsUI = () => {
308
397
  // Get reactive values from signals
309
398
  const isEditing = signals.isEditing.value
310
399
  const isAIProcessing = signals.isAIProcessing.value
311
- const isChatOpen = signals.isChatOpen.value
312
400
  const blockEditorState = signals.blockEditorState.value
313
401
  const colorEditorState = signals.colorEditorState.value
314
402
  const manifest = signals.manifest.value
@@ -384,7 +472,7 @@ const CmsUI = () => {
384
472
  onCompare: handleCompare,
385
473
  onSave: handleSave,
386
474
  onDiscard: handleDiscard,
387
- onAIChat: handleAIChatToggle,
475
+ onSelectElement: handleSelectElementToggle,
388
476
  onMediaLibrary: handleMediaLibrary,
389
477
  onDismissDeployment: handleDismissDeployment,
390
478
  onNavigateChange: () => {
@@ -400,18 +488,6 @@ const CmsUI = () => {
400
488
  />
401
489
  </ErrorBoundary>
402
490
 
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
491
  <ErrorBoundary componentName="Text Style Toolbar">
416
492
  <TextStyleToolbar
417
493
  visible={textSelectionState.hasSelection && isEditing && !isAIProcessing && isTextStylingAllowed}
@@ -439,17 +515,6 @@ const CmsUI = () => {
439
515
  />
440
516
  </ErrorBoundary>
441
517
 
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
518
  <ErrorBoundary componentName="Block Editor">
454
519
  <BlockEditor
455
520
  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
+ }
@@ -213,6 +213,7 @@ function createInitialAttributeEditorState(): AttributeEditorState {
213
213
 
214
214
  export const isEnabled = signal(false)
215
215
  export const isEditing = signal(false)
216
+ export const isSelectMode = signal(false)
216
217
  export const isSaving = signal(false)
217
218
  export const showingOriginal = signal(false)
218
219
  export const currentEditingId = signal<string | null>(null)
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