@nuasite/cms 0.8.0 → 0.8.3
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 +1 -1
- package/dist/editor.js +4760 -4685
- package/package.json +1 -1
- package/src/editor/components/bg-image-overlay.tsx +27 -0
- package/src/editor/editor.ts +5 -7
- package/src/editor/history.ts +120 -34
- package/src/editor/signals.ts +31 -16
- package/src/editor/types.ts +27 -7
- package/src/handlers/source-writer.ts +120 -58
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { Z_INDEX } from '../constants'
|
|
3
|
+
import { isApplyingUndoRedo, recordChange } from '../history'
|
|
3
4
|
import { cn } from '../lib/cn'
|
|
4
5
|
import * as signals from '../signals'
|
|
5
6
|
import { saveBgImageEditsToStorage } from '../storage'
|
|
@@ -410,6 +411,10 @@ function applyBgImageUpdate(
|
|
|
410
411
|
const change = signals.getPendingBgImageChange(cmsId)
|
|
411
412
|
if (!change) return
|
|
412
413
|
|
|
414
|
+
// Capture pre-mutation state for undo
|
|
415
|
+
const previousClassName = element.className
|
|
416
|
+
const previousStyleCssText = element.style.cssText
|
|
417
|
+
|
|
413
418
|
const newChange = { ...change }
|
|
414
419
|
|
|
415
420
|
// Apply bg image class change
|
|
@@ -451,6 +456,28 @@ function applyBgImageUpdate(
|
|
|
451
456
|
|| newChange.newBgPosition !== newChange.originalBgPosition
|
|
452
457
|
|| newChange.newBgRepeat !== newChange.originalBgRepeat
|
|
453
458
|
|
|
459
|
+
// Record undo action after DOM is mutated
|
|
460
|
+
if (!isApplyingUndoRedo) {
|
|
461
|
+
recordChange({
|
|
462
|
+
type: 'bgImage',
|
|
463
|
+
cmsId,
|
|
464
|
+
element,
|
|
465
|
+
previousClassName,
|
|
466
|
+
currentClassName: element.className,
|
|
467
|
+
previousStyleCssText,
|
|
468
|
+
currentStyleCssText: element.style.cssText,
|
|
469
|
+
previousBgImageClass: change.newBgImageClass,
|
|
470
|
+
currentBgImageClass: newChange.newBgImageClass,
|
|
471
|
+
previousBgSize: change.newBgSize,
|
|
472
|
+
currentBgSize: newChange.newBgSize,
|
|
473
|
+
previousBgPosition: change.newBgPosition,
|
|
474
|
+
currentBgPosition: newChange.newBgPosition,
|
|
475
|
+
previousBgRepeat: change.newBgRepeat,
|
|
476
|
+
currentBgRepeat: newChange.newBgRepeat,
|
|
477
|
+
wasDirty: change.isDirty,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
454
481
|
signals.setPendingBgImageChange(cmsId, newChange)
|
|
455
482
|
saveBgImageEditsToStorage(signals.pendingBgImageChanges.value)
|
|
456
483
|
}
|
package/src/editor/editor.ts
CHANGED
|
@@ -549,11 +549,9 @@ export function discardAllChanges(onStateChange?: () => void): void {
|
|
|
549
549
|
})
|
|
550
550
|
|
|
551
551
|
cleanupHighlightSystem()
|
|
552
|
-
signals.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
signals.clearPendingBgImageChanges()
|
|
556
|
-
signals.clearPendingAttributeChanges()
|
|
552
|
+
for (const entry of Object.values(signals.changeRegistry)) {
|
|
553
|
+
entry.mapSignal.value = new Map()
|
|
554
|
+
}
|
|
557
555
|
clearAllEditsFromStorage()
|
|
558
556
|
clearHistory()
|
|
559
557
|
stopEditMode(onStateChange)
|
|
@@ -701,7 +699,7 @@ export async function saveAllChanges(
|
|
|
701
699
|
sourcePath: bestSourcePath ?? entry?.sourcePath ?? '',
|
|
702
700
|
sourceLine: bestSourceLine ?? entry?.sourceLine ?? 0,
|
|
703
701
|
sourceSnippet: bestSourceSnippet ?? entry?.sourceSnippet ?? '',
|
|
704
|
-
|
|
702
|
+
styleChange: {
|
|
705
703
|
oldClass: origAttr?.value || '',
|
|
706
704
|
newClass: newAttr.value,
|
|
707
705
|
type: classType,
|
|
@@ -740,7 +738,7 @@ export async function saveAllChanges(
|
|
|
740
738
|
sourcePath: entry?.sourcePath ?? '',
|
|
741
739
|
sourceLine: entry?.sourceLine ?? 0,
|
|
742
740
|
sourceSnippet: entry?.sourceSnippet ?? '',
|
|
743
|
-
|
|
741
|
+
styleChange: {
|
|
744
742
|
oldClass: bgChange.oldClass,
|
|
745
743
|
newClass: bgChange.newClass,
|
|
746
744
|
type: bgChange.type,
|
package/src/editor/history.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { computed, signal } from '@preact/signals'
|
|
2
2
|
import * as signals from './signals'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
saveAttributeEditsToStorage,
|
|
5
|
+
saveBgImageEditsToStorage,
|
|
6
|
+
saveColorEditsToStorage,
|
|
7
|
+
saveEditsToStorage,
|
|
8
|
+
saveImageEditsToStorage,
|
|
9
|
+
} from './storage'
|
|
4
10
|
import type { Attribute, UndoAction, UndoTextAction } from './types'
|
|
5
11
|
|
|
6
12
|
// ============================================================================
|
|
@@ -159,52 +165,104 @@ function scrollToElement(element: HTMLElement): void {
|
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
167
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
168
|
+
/** Registry of undo/redo handlers keyed by action type.
|
|
169
|
+
* Adding a new UndoAction variant forces a TypeScript error until its handlers are added here. */
|
|
170
|
+
type UndoHandlers = {
|
|
171
|
+
[K in UndoAction['type']]: {
|
|
172
|
+
applyReverse: (action: Extract<UndoAction, { type: K }>) => void
|
|
173
|
+
applyForward: (action: Extract<UndoAction, { type: K }>) => void
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const undoRegistry: UndoHandlers = {
|
|
178
|
+
text: {
|
|
179
|
+
applyReverse: (action) => {
|
|
165
180
|
scrollToElement(action.element)
|
|
166
181
|
applyTextState(action.cmsId, action.element, action.previousHTML, action.previousText, action.wasDirty)
|
|
167
|
-
|
|
168
|
-
|
|
182
|
+
},
|
|
183
|
+
applyForward: (action) => {
|
|
184
|
+
scrollToElement(action.element)
|
|
185
|
+
applyTextState(action.cmsId, action.element, action.currentHTML, action.currentText, true)
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
image: {
|
|
189
|
+
applyReverse: (action) => {
|
|
169
190
|
scrollToElement(action.element)
|
|
170
191
|
applyImageState(action.cmsId, action.element, action.previousSrc, action.previousAlt, action.wasDirty)
|
|
171
|
-
|
|
172
|
-
|
|
192
|
+
},
|
|
193
|
+
applyForward: (action) => {
|
|
194
|
+
scrollToElement(action.element)
|
|
195
|
+
applyImageState(action.cmsId, action.element, action.currentSrc, action.currentAlt, true)
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
color: {
|
|
199
|
+
applyReverse: (action) => {
|
|
173
200
|
scrollToElement(action.element)
|
|
174
201
|
applyColorState(action.cmsId, action.element, action.previousClassName, action.previousStyleCssText, action.previousClasses, action.wasDirty)
|
|
175
|
-
|
|
176
|
-
|
|
202
|
+
},
|
|
203
|
+
applyForward: (action) => {
|
|
177
204
|
scrollToElement(action.element)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function applyForward(action: UndoAction): void {
|
|
187
|
-
switch (action.type) {
|
|
188
|
-
case 'text':
|
|
205
|
+
applyColorState(action.cmsId, action.element, action.currentClassName, action.currentStyleCssText, action.currentClasses, true)
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
bgImage: {
|
|
209
|
+
applyReverse: (action) => {
|
|
189
210
|
scrollToElement(action.element)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
applyBgImageState(
|
|
212
|
+
action.cmsId,
|
|
213
|
+
action.element,
|
|
214
|
+
action.previousClassName,
|
|
215
|
+
action.previousStyleCssText,
|
|
216
|
+
action.previousBgImageClass,
|
|
217
|
+
action.previousBgSize,
|
|
218
|
+
action.previousBgPosition,
|
|
219
|
+
action.previousBgRepeat,
|
|
220
|
+
action.wasDirty,
|
|
221
|
+
)
|
|
222
|
+
},
|
|
223
|
+
applyForward: (action) => {
|
|
193
224
|
scrollToElement(action.element)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
225
|
+
applyBgImageState(
|
|
226
|
+
action.cmsId,
|
|
227
|
+
action.element,
|
|
228
|
+
action.currentClassName,
|
|
229
|
+
action.currentStyleCssText,
|
|
230
|
+
action.currentBgImageClass,
|
|
231
|
+
action.currentBgSize,
|
|
232
|
+
action.currentBgPosition,
|
|
233
|
+
action.currentBgRepeat,
|
|
234
|
+
true,
|
|
235
|
+
)
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
attribute: {
|
|
239
|
+
applyReverse: (action) => {
|
|
197
240
|
scrollToElement(action.element)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
241
|
+
applyAttributeState(action.cmsId, action.element, action.previousAttributes, action.wasDirty)
|
|
242
|
+
},
|
|
243
|
+
applyForward: (action) => {
|
|
201
244
|
scrollToElement(action.element)
|
|
202
245
|
applyAttributeState(action.cmsId, action.element, action.currentAttributes, true)
|
|
203
|
-
|
|
204
|
-
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
seo: {
|
|
249
|
+
applyReverse: (action) => {
|
|
250
|
+
applySeoState(action.cmsId, action.previousValue, action.originalValue, action.wasDirty)
|
|
251
|
+
},
|
|
252
|
+
applyForward: (action) => {
|
|
205
253
|
applySeoState(action.cmsId, action.currentValue, action.originalValue, true)
|
|
206
|
-
|
|
207
|
-
}
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function applyReverse(action: UndoAction): void {
|
|
259
|
+
const handlers = undoRegistry[action.type] as UndoHandlers[typeof action.type]
|
|
260
|
+
handlers.applyReverse(action as any)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function applyForward(action: UndoAction): void {
|
|
264
|
+
const handlers = undoRegistry[action.type] as UndoHandlers[typeof action.type]
|
|
265
|
+
handlers.applyForward(action as any)
|
|
208
266
|
}
|
|
209
267
|
|
|
210
268
|
// ============================================================================
|
|
@@ -323,6 +381,34 @@ function applySeoState(
|
|
|
323
381
|
})
|
|
324
382
|
}
|
|
325
383
|
|
|
384
|
+
function applyBgImageState(
|
|
385
|
+
cmsId: string,
|
|
386
|
+
element: HTMLElement,
|
|
387
|
+
className: string,
|
|
388
|
+
styleCssText: string,
|
|
389
|
+
bgImageClass: string,
|
|
390
|
+
bgSize: string,
|
|
391
|
+
bgPosition: string,
|
|
392
|
+
bgRepeat: string,
|
|
393
|
+
isDirty: boolean,
|
|
394
|
+
): void {
|
|
395
|
+
if (element.isConnected) {
|
|
396
|
+
element.className = className
|
|
397
|
+
element.style.cssText = styleCssText
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
signals.updatePendingBgImageChange(cmsId, (c) => ({
|
|
401
|
+
...c,
|
|
402
|
+
newBgImageClass: bgImageClass,
|
|
403
|
+
newBgSize: bgSize,
|
|
404
|
+
newBgPosition: bgPosition,
|
|
405
|
+
newBgRepeat: bgRepeat,
|
|
406
|
+
isDirty,
|
|
407
|
+
}))
|
|
408
|
+
|
|
409
|
+
saveBgImageEditsToStorage(signals.pendingBgImageChanges.value)
|
|
410
|
+
}
|
|
411
|
+
|
|
326
412
|
// ============================================================================
|
|
327
413
|
// Clear History
|
|
328
414
|
// ============================================================================
|
package/src/editor/signals.ts
CHANGED
|
@@ -496,6 +496,16 @@ let toastIdCounter = 0
|
|
|
496
496
|
// Computed Values - Dirty Tracking
|
|
497
497
|
// ============================================================================
|
|
498
498
|
|
|
499
|
+
/** All change categories that participate in dirty tracking.
|
|
500
|
+
* Adding a new category forces a TypeScript error until it's added to changeRegistry. */
|
|
501
|
+
type ChangeCategory = 'text' | 'image' | 'color' | 'bgImage' | 'seo' | 'attribute'
|
|
502
|
+
|
|
503
|
+
interface ChangeRegistryEntry {
|
|
504
|
+
mapSignal: Signal<Map<string, any>>
|
|
505
|
+
dirtyCount: ReturnType<typeof computed<number>>
|
|
506
|
+
hasDirty: ReturnType<typeof computed<boolean>>
|
|
507
|
+
}
|
|
508
|
+
|
|
499
509
|
// Use factory for dirty tracking to reduce duplication
|
|
500
510
|
const _pendingChangesDirty = createDirtyTracking(pendingChanges)
|
|
501
511
|
const _pendingImageChangesDirty = createDirtyTracking(pendingImageChanges)
|
|
@@ -504,6 +514,19 @@ const _pendingBgImageChangesDirty = createDirtyTracking(pendingBgImageChanges)
|
|
|
504
514
|
const _pendingSeoChangesDirty = createDirtyTracking(pendingSeoChanges)
|
|
505
515
|
const _pendingAttributeChangesDirty = createDirtyTracking(pendingAttributeChanges)
|
|
506
516
|
|
|
517
|
+
export const changeRegistry: Record<ChangeCategory, ChangeRegistryEntry> = {
|
|
518
|
+
text: { mapSignal: pendingChanges, dirtyCount: _pendingChangesDirty.dirtyCount, hasDirty: _pendingChangesDirty.hasDirty },
|
|
519
|
+
image: { mapSignal: pendingImageChanges, dirtyCount: _pendingImageChangesDirty.dirtyCount, hasDirty: _pendingImageChangesDirty.hasDirty },
|
|
520
|
+
color: { mapSignal: pendingColorChanges, dirtyCount: _pendingColorChangesDirty.dirtyCount, hasDirty: _pendingColorChangesDirty.hasDirty },
|
|
521
|
+
bgImage: { mapSignal: pendingBgImageChanges, dirtyCount: _pendingBgImageChangesDirty.dirtyCount, hasDirty: _pendingBgImageChangesDirty.hasDirty },
|
|
522
|
+
seo: { mapSignal: pendingSeoChanges, dirtyCount: _pendingSeoChangesDirty.dirtyCount, hasDirty: _pendingSeoChangesDirty.hasDirty },
|
|
523
|
+
attribute: {
|
|
524
|
+
mapSignal: pendingAttributeChanges,
|
|
525
|
+
dirtyCount: _pendingAttributeChangesDirty.dirtyCount,
|
|
526
|
+
hasDirty: _pendingAttributeChangesDirty.hasDirty,
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
|
|
507
530
|
export const dirtyChangesCount = _pendingChangesDirty.dirtyCount
|
|
508
531
|
export const dirtyChanges = _pendingChangesDirty.dirtyItems
|
|
509
532
|
export const hasDirtyChanges = _pendingChangesDirty.hasDirty
|
|
@@ -528,17 +551,11 @@ export const dirtyAttributeChangesCount = _pendingAttributeChangesDirty.dirtyCou
|
|
|
528
551
|
export const dirtyAttributeChanges = _pendingAttributeChangesDirty.dirtyItems
|
|
529
552
|
export const hasDirtyAttributeChanges = _pendingAttributeChangesDirty.hasDirty
|
|
530
553
|
|
|
531
|
-
|
|
532
|
-
() =>
|
|
533
|
-
dirtyChangesCount.value + dirtyImageChangesCount.value + dirtyColorChangesCount.value + dirtyBgImageChangesCount.value + dirtySeoChangesCount.value
|
|
534
|
-
+ dirtyAttributeChangesCount.value,
|
|
535
|
-
)
|
|
554
|
+
const _registryEntries = Object.values(changeRegistry)
|
|
536
555
|
|
|
537
|
-
export const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|| hasDirtyAttributeChanges.value,
|
|
541
|
-
)
|
|
556
|
+
export const totalDirtyCount = computed(() => _registryEntries.reduce((sum, entry) => sum + entry.dirtyCount.value, 0))
|
|
557
|
+
|
|
558
|
+
export const hasAnyDirtyChanges = computed(() => _registryEntries.some((entry) => entry.hasDirty.value))
|
|
542
559
|
|
|
543
560
|
// Navigation index for cycling through dirty elements
|
|
544
561
|
export const changeNavigationIndex = signal<number>(0)
|
|
@@ -1345,14 +1362,12 @@ export function resetAllState(): void {
|
|
|
1345
1362
|
showingOriginal.value = false
|
|
1346
1363
|
currentEditingId.value = null
|
|
1347
1364
|
currentComponentId.value = null
|
|
1348
|
-
|
|
1365
|
+
for (const entry of Object.values(changeRegistry)) {
|
|
1366
|
+
entry.mapSignal.value = new Map()
|
|
1367
|
+
}
|
|
1368
|
+
// Non-dirty-tracked maps still cleared individually
|
|
1349
1369
|
pendingComponentChanges.value = new Map()
|
|
1350
1370
|
pendingInserts.value = new Map()
|
|
1351
|
-
pendingImageChanges.value = new Map()
|
|
1352
|
-
pendingColorChanges.value = new Map()
|
|
1353
|
-
pendingBgImageChanges.value = new Map()
|
|
1354
|
-
pendingSeoChanges.value = new Map()
|
|
1355
|
-
pendingAttributeChanges.value = new Map()
|
|
1356
1371
|
manifest.value = { entries: {}, components: {}, componentDefinitions: {} }
|
|
1357
1372
|
aiState.value = createInitialAIState()
|
|
1358
1373
|
blockEditorState.value = createInitialBlockEditorState()
|
package/src/editor/types.ts
CHANGED
|
@@ -170,13 +170,13 @@ export interface SavedBackgroundImageEdits {
|
|
|
170
170
|
[cmsId: string]: SavedBackgroundImageEdit
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
/**
|
|
174
|
-
export interface
|
|
175
|
-
/** The
|
|
173
|
+
/** Style change details for updating element classes (colors, text styles, bg images) */
|
|
174
|
+
export interface StyleChangePayload {
|
|
175
|
+
/** The class to replace (e.g., 'bg-blue-500') */
|
|
176
176
|
oldClass: string
|
|
177
|
-
/** The new
|
|
177
|
+
/** The new class (e.g., 'bg-red-500') */
|
|
178
178
|
newClass: string
|
|
179
|
-
/** Type of
|
|
179
|
+
/** Type of style change */
|
|
180
180
|
type:
|
|
181
181
|
| 'bg'
|
|
182
182
|
| 'text'
|
|
@@ -220,8 +220,8 @@ export interface ChangePayload {
|
|
|
220
220
|
newSrc: string
|
|
221
221
|
newAlt?: string
|
|
222
222
|
}
|
|
223
|
-
/**
|
|
224
|
-
|
|
223
|
+
/** Style class change (colors, text styles, bg images) */
|
|
224
|
+
styleChange?: StyleChangePayload
|
|
225
225
|
/** Attribute changes (for links, forms, etc.) */
|
|
226
226
|
attributeChanges?: AttributeChangePayload[]
|
|
227
227
|
}
|
|
@@ -630,12 +630,32 @@ export interface UndoSeoAction {
|
|
|
630
630
|
wasDirty: boolean
|
|
631
631
|
}
|
|
632
632
|
|
|
633
|
+
export interface UndoBgImageAction {
|
|
634
|
+
type: 'bgImage'
|
|
635
|
+
cmsId: string
|
|
636
|
+
element: HTMLElement
|
|
637
|
+
previousClassName: string
|
|
638
|
+
currentClassName: string
|
|
639
|
+
previousStyleCssText: string
|
|
640
|
+
currentStyleCssText: string
|
|
641
|
+
previousBgImageClass: string
|
|
642
|
+
currentBgImageClass: string
|
|
643
|
+
previousBgSize: string
|
|
644
|
+
currentBgSize: string
|
|
645
|
+
previousBgPosition: string
|
|
646
|
+
currentBgPosition: string
|
|
647
|
+
previousBgRepeat: string
|
|
648
|
+
currentBgRepeat: string
|
|
649
|
+
wasDirty: boolean
|
|
650
|
+
}
|
|
651
|
+
|
|
633
652
|
export type UndoAction =
|
|
634
653
|
| UndoTextAction
|
|
635
654
|
| UndoImageAction
|
|
636
655
|
| UndoColorAction
|
|
637
656
|
| UndoAttributeAction
|
|
638
657
|
| UndoSeoAction
|
|
658
|
+
| UndoBgImageAction
|
|
639
659
|
|
|
640
660
|
declare global {
|
|
641
661
|
interface Window {
|
|
@@ -1,53 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { getProjectRoot } from '../config'
|
|
4
|
+
import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '../editor/types'
|
|
4
5
|
import type { ManifestWriter } from '../manifest-writer'
|
|
5
6
|
import type { CmsManifest, ManifestEntry } from '../types'
|
|
6
7
|
import { acquireFileLock, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
|
|
7
8
|
|
|
8
|
-
export interface ColorChangePayload {
|
|
9
|
-
oldClass: string
|
|
10
|
-
newClass: string
|
|
11
|
-
type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' | 'fontWeight' | 'fontStyle' | 'textDecoration' | 'fontSize'
|
|
12
|
-
sourcePath?: string
|
|
13
|
-
sourceLine?: number
|
|
14
|
-
sourceSnippet?: string
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ImageChangePayload {
|
|
18
|
-
newSrc: string
|
|
19
|
-
newAlt: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface AttributeChangePayload {
|
|
23
|
-
attributeName: string
|
|
24
|
-
oldValue: string | undefined
|
|
25
|
-
newValue: string | undefined
|
|
26
|
-
sourcePath?: string
|
|
27
|
-
sourceLine?: number
|
|
28
|
-
sourceSnippet?: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ChangePayload {
|
|
32
|
-
cmsId: string
|
|
33
|
-
newValue: string
|
|
34
|
-
originalValue: string
|
|
35
|
-
sourcePath: string
|
|
36
|
-
sourceLine: number
|
|
37
|
-
sourceSnippet: string
|
|
38
|
-
htmlValue?: string
|
|
39
|
-
childCmsIds?: string[]
|
|
40
|
-
hasStyledContent?: boolean
|
|
41
|
-
colorChange?: ColorChangePayload
|
|
42
|
-
imageChange?: ImageChangePayload
|
|
43
|
-
attributeChanges?: AttributeChangePayload[]
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface SaveBatchRequest {
|
|
47
|
-
changes: ChangePayload[]
|
|
48
|
-
meta: { source: string; url: string }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
9
|
export interface SaveBatchResponse {
|
|
52
10
|
updated: number
|
|
53
11
|
errors?: Array<{ cmsId: string; error: string }>
|
|
@@ -157,8 +115,8 @@ function applyChanges(
|
|
|
157
115
|
continue
|
|
158
116
|
}
|
|
159
117
|
|
|
160
|
-
// Handle
|
|
161
|
-
if (change.
|
|
118
|
+
// Handle style class changes (colors, text styles, bg images)
|
|
119
|
+
if (change.styleChange) {
|
|
162
120
|
const result = applyColorChange(newContent, change)
|
|
163
121
|
if (result.success) {
|
|
164
122
|
newContent = result.content
|
|
@@ -323,10 +281,10 @@ function applyColorChange(
|
|
|
323
281
|
content: string,
|
|
324
282
|
change: ChangePayload,
|
|
325
283
|
): { success: true; content: string } | { success: false; error: string } {
|
|
326
|
-
const { oldClass, newClass } = change.
|
|
327
|
-
// Prefer
|
|
284
|
+
const { oldClass, newClass } = change.styleChange!
|
|
285
|
+
// Prefer styleChange's own sourceLine (points to the class attribute)
|
|
328
286
|
// over the outer change.sourceLine (may point to a data declaration)
|
|
329
|
-
const sourceLine = change.
|
|
287
|
+
const sourceLine = change.styleChange!.sourceLine ?? change.sourceLine
|
|
330
288
|
|
|
331
289
|
// When oldClass is empty, we're adding a new color class (not replacing)
|
|
332
290
|
if (!oldClass) {
|
|
@@ -536,16 +494,13 @@ function applyAttributeChanges(
|
|
|
536
494
|
return { content: newContent, appliedCount: attrApplied, failedChanges }
|
|
537
495
|
}
|
|
538
496
|
|
|
539
|
-
function applyTextChange(
|
|
497
|
+
export function applyTextChange(
|
|
540
498
|
content: string,
|
|
541
499
|
change: ChangePayload,
|
|
542
500
|
manifest: CmsManifest,
|
|
543
501
|
): { success: true; content: string } | { success: false; error: string } {
|
|
544
502
|
const { sourceSnippet, originalValue, newValue, htmlValue } = change
|
|
545
503
|
|
|
546
|
-
let newText = htmlValue ?? newValue
|
|
547
|
-
newText = resolveCmsPlaceholders(newText, manifest)
|
|
548
|
-
|
|
549
504
|
if (!sourceSnippet || !originalValue) {
|
|
550
505
|
if (change.attributeChanges && change.attributeChanges.length > 0) {
|
|
551
506
|
return { success: true, content }
|
|
@@ -557,22 +512,103 @@ function applyTextChange(
|
|
|
557
512
|
return { success: false, error: 'Source snippet not found in file' }
|
|
558
513
|
}
|
|
559
514
|
|
|
560
|
-
|
|
561
|
-
|
|
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)
|
|
562
533
|
|
|
563
534
|
if (updatedSnippet === sourceSnippet) {
|
|
564
|
-
//
|
|
565
|
-
const matchedText = findTextInSnippet(sourceSnippet,
|
|
535
|
+
// resolvedOriginal wasn't found in snippet - try HTML entity handling
|
|
536
|
+
const matchedText = findTextInSnippet(sourceSnippet, resolvedOriginal)
|
|
566
537
|
if (matchedText) {
|
|
567
|
-
const updatedWithEntity = sourceSnippet.replace(matchedText,
|
|
538
|
+
const updatedWithEntity = sourceSnippet.replace(matchedText, resolvedNewText)
|
|
568
539
|
return { success: true, content: content.replace(sourceSnippet, updatedWithEntity) }
|
|
569
540
|
}
|
|
541
|
+
// Try inner content replacement for text spanning inline HTML elements
|
|
542
|
+
// (e.g., <h3>text part 1 <span class="...">text part 2</span></h3>)
|
|
543
|
+
const innerMatch = sourceSnippet.match(/^(\s*<(\w+)\b[^>]*>)([\s\S]*)(<\/\2>\s*)$/)
|
|
544
|
+
if (innerMatch) {
|
|
545
|
+
const [, openTag, , innerContent, closeTag] = innerMatch
|
|
546
|
+
const textOnly = innerContent!.replace(/<[^>]+>/g, '')
|
|
547
|
+
if (textOnly === resolvedOriginal) {
|
|
548
|
+
return { success: true, content: content.replace(sourceSnippet, openTag + resolvedNewText + closeTag) }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
570
552
|
return {
|
|
571
553
|
success: false,
|
|
572
|
-
error: `Original text "${
|
|
554
|
+
error: `Original text "${resolvedOriginal.substring(0, 50)}..." not found in source snippet`,
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return { success: true, content: content.replace(sourceSnippet, updatedSnippet) }
|
|
559
|
+
}
|
|
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
|
+
}
|
|
573
605
|
}
|
|
574
606
|
}
|
|
575
607
|
|
|
608
|
+
if (!anyChange) {
|
|
609
|
+
return { success: false, error: 'No text changes detected between original and new values' }
|
|
610
|
+
}
|
|
611
|
+
|
|
576
612
|
return { success: true, content: content.replace(sourceSnippet, updatedSnippet) }
|
|
577
613
|
}
|
|
578
614
|
|
|
@@ -616,6 +652,32 @@ function findTextInSnippet(snippet: string, decodedText: string): string | null
|
|
|
616
652
|
return brMatch && brMatch[0] !== decodedText ? brMatch[0] : null
|
|
617
653
|
}
|
|
618
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
|
+
|
|
619
681
|
/**
|
|
620
682
|
* Resolve CMS placeholders like {{cms:cms-96}} in text.
|
|
621
683
|
*/
|