@nuasite/cms 0.8.3 → 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.3",
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
+ }
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