@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/README.md +76 -0
- package/dist/editor.js +7948 -8356
- package/package.json +1 -1
- package/src/editor/index.tsx +103 -26
- package/src/editor/post-message.ts +136 -0
- package/src/handlers/source-writer.ts +101 -15
- package/src/index.ts +9 -0
- package/src/types.ts +145 -0
package/package.json
CHANGED
package/src/editor/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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,
|
|
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 +
|
|
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('&')) {
|
|
664
|
+
result = result.replace(/&/g, '&')
|
|
665
|
+
}
|
|
666
|
+
if (reference.includes('<')) {
|
|
667
|
+
result = result.replace(/</g, '<')
|
|
668
|
+
}
|
|
669
|
+
if (reference.includes('>')) {
|
|
670
|
+
result = result.replace(/>/g, '>')
|
|
671
|
+
}
|
|
672
|
+
if (reference.includes('"')) {
|
|
673
|
+
result = result.replace(/"/g, '"')
|
|
674
|
+
}
|
|
675
|
+
if (reference.includes(''') || reference.includes(''')) {
|
|
676
|
+
result = result.replace(/'/g, ''')
|
|
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
|