@nuasite/cms 0.7.3 → 0.8.2

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.
@@ -27,6 +27,7 @@ import type {
27
27
  MediaItem,
28
28
  MediaLibraryState,
29
29
  PendingAttributeChange,
30
+ PendingBackgroundImageChange,
30
31
  PendingChange,
31
32
  PendingColorChange,
32
33
  PendingComponentInsert,
@@ -231,6 +232,9 @@ export const pendingImageChanges = signal<Map<string, PendingImageChange>>(
231
232
  export const pendingColorChanges = signal<Map<string, PendingColorChange>>(
232
233
  new Map(),
233
234
  )
235
+ export const pendingBgImageChanges = signal<Map<string, PendingBackgroundImageChange>>(
236
+ new Map(),
237
+ )
234
238
  export const manifest = signal<CmsManifest>({
235
239
  entries: {},
236
240
  components: {},
@@ -252,6 +256,7 @@ const _pendingComponentChangesHelpers = createMapHelpers(pendingComponentChanges
252
256
  const _pendingInsertsHelpers = createMapHelpers(pendingInserts)
253
257
  const _pendingImageChangesHelpers = createMapHelpers(pendingImageChanges)
254
258
  const _pendingColorChangesHelpers = createMapHelpers(pendingColorChanges)
259
+ const _pendingBgImageChangesHelpers = createMapHelpers(pendingBgImageChanges)
255
260
 
256
261
  // ============================================================================
257
262
  // AI State Signals
@@ -491,13 +496,37 @@ let toastIdCounter = 0
491
496
  // Computed Values - Dirty Tracking
492
497
  // ============================================================================
493
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
+
494
509
  // Use factory for dirty tracking to reduce duplication
495
510
  const _pendingChangesDirty = createDirtyTracking(pendingChanges)
496
511
  const _pendingImageChangesDirty = createDirtyTracking(pendingImageChanges)
497
512
  const _pendingColorChangesDirty = createDirtyTracking(pendingColorChanges)
513
+ const _pendingBgImageChangesDirty = createDirtyTracking(pendingBgImageChanges)
498
514
  const _pendingSeoChangesDirty = createDirtyTracking(pendingSeoChanges)
499
515
  const _pendingAttributeChangesDirty = createDirtyTracking(pendingAttributeChanges)
500
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
+
501
530
  export const dirtyChangesCount = _pendingChangesDirty.dirtyCount
502
531
  export const dirtyChanges = _pendingChangesDirty.dirtyItems
503
532
  export const hasDirtyChanges = _pendingChangesDirty.hasDirty
@@ -510,6 +539,10 @@ export const dirtyColorChangesCount = _pendingColorChangesDirty.dirtyCount
510
539
  export const dirtyColorChanges = _pendingColorChangesDirty.dirtyItems
511
540
  export const hasDirtyColorChanges = _pendingColorChangesDirty.hasDirty
512
541
 
542
+ export const dirtyBgImageChangesCount = _pendingBgImageChangesDirty.dirtyCount
543
+ export const dirtyBgImageChanges = _pendingBgImageChangesDirty.dirtyItems
544
+ export const hasDirtyBgImageChanges = _pendingBgImageChangesDirty.hasDirty
545
+
513
546
  export const dirtySeoChangesCount = _pendingSeoChangesDirty.dirtyCount
514
547
  export const dirtySeoChanges = _pendingSeoChangesDirty.dirtyItems
515
548
  export const hasDirtySeoChanges = _pendingSeoChangesDirty.hasDirty
@@ -518,16 +551,11 @@ export const dirtyAttributeChangesCount = _pendingAttributeChangesDirty.dirtyCou
518
551
  export const dirtyAttributeChanges = _pendingAttributeChangesDirty.dirtyItems
519
552
  export const hasDirtyAttributeChanges = _pendingAttributeChangesDirty.hasDirty
520
553
 
521
- export const totalDirtyCount = computed(
522
- () =>
523
- dirtyChangesCount.value + dirtyImageChangesCount.value + dirtyColorChangesCount.value + dirtySeoChangesCount.value
524
- + dirtyAttributeChangesCount.value,
525
- )
554
+ const _registryEntries = Object.values(changeRegistry)
526
555
 
527
- export const hasAnyDirtyChanges = computed(
528
- () =>
529
- hasDirtyChanges.value || hasDirtyImageChanges.value || hasDirtyColorChanges.value || hasDirtySeoChanges.value || hasDirtyAttributeChanges.value,
530
- )
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))
531
559
 
532
560
  // Navigation index for cycling through dirty elements
533
561
  export const changeNavigationIndex = signal<number>(0)
@@ -615,6 +643,13 @@ export const deletePendingColorChange = _pendingColorChangesHelpers.delete
615
643
  export const clearPendingColorChanges = _pendingColorChangesHelpers.clear
616
644
  export const getPendingColorChange = _pendingColorChangesHelpers.get
617
645
 
646
+ // Background image changes mutations - using helpers
647
+ export const setPendingBgImageChange = _pendingBgImageChangesHelpers.set
648
+ export const updatePendingBgImageChange = _pendingBgImageChangesHelpers.update
649
+ export const deletePendingBgImageChange = _pendingBgImageChangesHelpers.delete
650
+ export const clearPendingBgImageChanges = _pendingBgImageChangesHelpers.clear
651
+ export const getPendingBgImageChange = _pendingBgImageChangesHelpers.get
652
+
618
653
  // SEO changes mutations - using helpers
619
654
  export const setPendingSeoChange = _pendingSeoChangesHelpers.set
620
655
  export const updatePendingSeoChange = _pendingSeoChangesHelpers.update
@@ -1327,13 +1362,12 @@ export function resetAllState(): void {
1327
1362
  showingOriginal.value = false
1328
1363
  currentEditingId.value = null
1329
1364
  currentComponentId.value = null
1330
- pendingChanges.value = new Map()
1365
+ for (const entry of Object.values(changeRegistry)) {
1366
+ entry.mapSignal.value = new Map()
1367
+ }
1368
+ // Non-dirty-tracked maps still cleared individually
1331
1369
  pendingComponentChanges.value = new Map()
1332
1370
  pendingInserts.value = new Map()
1333
- pendingImageChanges.value = new Map()
1334
- pendingColorChanges.value = new Map()
1335
- pendingSeoChanges.value = new Map()
1336
- pendingAttributeChanges.value = new Map()
1337
1371
  manifest.value = { entries: {}, components: {}, componentDefinitions: {} }
1338
1372
  aiState.value = createInitialAIState()
1339
1373
  blockEditorState.value = createInitialBlockEditorState()
@@ -2,10 +2,12 @@ import { STORAGE_KEYS } from './constants'
2
2
  import type {
3
3
  CmsSettings,
4
4
  PendingAttributeChange,
5
+ PendingBackgroundImageChange,
5
6
  PendingChange,
6
7
  PendingColorChange,
7
8
  PendingImageChange,
8
9
  SavedAttributeEdits,
10
+ SavedBackgroundImageEdits,
9
11
  SavedColorEdits,
10
12
  SavedEdits,
11
13
  SavedImageEdits,
@@ -180,6 +182,53 @@ export function clearAttributeEditsFromStorage(): void {
180
182
  }
181
183
  }
182
184
 
185
+ // ============================================================================
186
+ // Background Image Edits
187
+ // ============================================================================
188
+
189
+ export function saveBgImageEditsToStorage(pendingBgImageChanges: Map<string, PendingBackgroundImageChange>): void {
190
+ const edits: SavedBackgroundImageEdits = {}
191
+
192
+ pendingBgImageChanges.forEach((change, cmsId) => {
193
+ if (change.isDirty) {
194
+ edits[cmsId] = {
195
+ originalBgImageClass: change.originalBgImageClass,
196
+ newBgImageClass: change.newBgImageClass,
197
+ originalBgSize: change.originalBgSize,
198
+ newBgSize: change.newBgSize,
199
+ originalBgPosition: change.originalBgPosition,
200
+ newBgPosition: change.newBgPosition,
201
+ originalBgRepeat: change.originalBgRepeat,
202
+ newBgRepeat: change.newBgRepeat,
203
+ }
204
+ }
205
+ })
206
+
207
+ try {
208
+ sessionStorage.setItem(STORAGE_KEYS.PENDING_BG_IMAGE_EDITS, JSON.stringify(edits))
209
+ } catch (e) {
210
+ console.warn('[CMS] Failed to save bg image edits to storage:', e)
211
+ }
212
+ }
213
+
214
+ export function loadBgImageEditsFromStorage(): SavedBackgroundImageEdits {
215
+ try {
216
+ const stored = sessionStorage.getItem(STORAGE_KEYS.PENDING_BG_IMAGE_EDITS)
217
+ return stored ? JSON.parse(stored) : {}
218
+ } catch (e) {
219
+ console.warn('[CMS] Failed to load bg image edits from storage:', e)
220
+ return {}
221
+ }
222
+ }
223
+
224
+ export function clearBgImageEditsFromStorage(): void {
225
+ try {
226
+ sessionStorage.removeItem(STORAGE_KEYS.PENDING_BG_IMAGE_EDITS)
227
+ } catch (e) {
228
+ console.warn('[CMS] Failed to clear bg image edits from storage:', e)
229
+ }
230
+ }
231
+
183
232
  // ============================================================================
184
233
  // Settings
185
234
  // ============================================================================
@@ -287,4 +336,5 @@ export function clearAllEditsFromStorage(): void {
287
336
  clearImageEditsFromStorage()
288
337
  clearColorEditsFromStorage()
289
338
  clearAttributeEditsFromStorage()
339
+ clearBgImageEditsFromStorage()
290
340
  }
@@ -5,6 +5,7 @@ export type {
5
5
  Attribute,
6
6
  AvailableColors,
7
7
  AvailableTextStyles,
8
+ BackgroundImageMetadata,
8
9
  CanonicalUrl,
9
10
  CmsManifest,
10
11
  CollectionDefinition,
@@ -94,6 +95,28 @@ export interface PendingColorChange {
94
95
  isDirty: boolean
95
96
  }
96
97
 
98
+ export interface PendingBackgroundImageChange {
99
+ element: HTMLElement
100
+ cmsId: string
101
+ /** Original bg-[url()] class */
102
+ originalBgImageClass: string
103
+ /** Current bg-[url()] class */
104
+ newBgImageClass: string
105
+ /** Original bg-size class (or empty) */
106
+ originalBgSize: string
107
+ /** Current bg-size class */
108
+ newBgSize: string
109
+ /** Original bg-position class (or empty) */
110
+ originalBgPosition: string
111
+ /** Current bg-position class */
112
+ newBgPosition: string
113
+ /** Original bg-repeat class (or empty) */
114
+ originalBgRepeat: string
115
+ /** Current bg-repeat class */
116
+ newBgRepeat: string
117
+ isDirty: boolean
118
+ }
119
+
97
120
  export interface ColorEditorState {
98
121
  isOpen: boolean
99
122
  targetElementId: string | null
@@ -132,14 +155,42 @@ export interface SavedColorEdits {
132
155
  [cmsId: string]: SavedColorEdit
133
156
  }
134
157
 
135
- /** Color change details for updating element color classes */
136
- export interface ColorChangePayload {
137
- /** The color class to replace (e.g., 'bg-blue-500') */
158
+ export interface SavedBackgroundImageEdit {
159
+ originalBgImageClass: string
160
+ newBgImageClass: string
161
+ originalBgSize: string
162
+ newBgSize: string
163
+ originalBgPosition: string
164
+ newBgPosition: string
165
+ originalBgRepeat: string
166
+ newBgRepeat: string
167
+ }
168
+
169
+ export interface SavedBackgroundImageEdits {
170
+ [cmsId: string]: SavedBackgroundImageEdit
171
+ }
172
+
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') */
138
176
  oldClass: string
139
- /** The new color class (e.g., 'bg-red-500') */
177
+ /** The new class (e.g., 'bg-red-500') */
140
178
  newClass: string
141
- /** Type of color/style change */
142
- type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' | 'fontWeight' | 'fontStyle' | 'textDecoration' | 'fontSize'
179
+ /** Type of style change */
180
+ type:
181
+ | 'bg'
182
+ | 'text'
183
+ | 'border'
184
+ | 'hoverBg'
185
+ | 'hoverText'
186
+ | 'fontWeight'
187
+ | 'fontStyle'
188
+ | 'textDecoration'
189
+ | 'fontSize'
190
+ | 'bgImage'
191
+ | 'bgSize'
192
+ | 'bgPosition'
193
+ | 'bgRepeat'
143
194
  /** Path to the source file where the color class is defined */
144
195
  sourcePath?: string
145
196
  /** Line number where the color class is defined */
@@ -169,8 +220,8 @@ export interface ChangePayload {
169
220
  newSrc: string
170
221
  newAlt?: string
171
222
  }
172
- /** Color class change (for buttons, etc.) */
173
- colorChange?: ColorChangePayload
223
+ /** Style class change (colors, text styles, bg images) */
224
+ styleChange?: StyleChangePayload
174
225
  /** Attribute changes (for links, forms, etc.) */
175
226
  attributeChanges?: AttributeChangePayload[]
176
227
  }
@@ -579,12 +630,32 @@ export interface UndoSeoAction {
579
630
  wasDirty: boolean
580
631
  }
581
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
+
582
652
  export type UndoAction =
583
653
  | UndoTextAction
584
654
  | UndoImageAction
585
655
  | UndoColorAction
586
656
  | UndoAttributeAction
587
657
  | UndoSeoAction
658
+ | UndoBgImageAction
588
659
 
589
660
  declare global {
590
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 color class changes
161
- if (change.colorChange) {
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.colorChange!
327
- // Prefer colorChange's own sourceLine (points to the class attribute)
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.colorChange!.sourceLine ?? change.sourceLine
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) {
@@ -346,10 +304,12 @@ function replaceClassInAttribute(
346
304
  newClass: string,
347
305
  sourceLine?: number,
348
306
  ): { success: true; content: string } | { success: false; error: string } {
349
- const classAttrPattern = /(class\s*=\s*)(["'])([^"']*)\2/
350
-
351
307
  const replaceOnLine = (line: string): string | null => {
352
- const match = line.match(classAttrPattern)
308
+ // Build pattern dynamically to only exclude the actual quote character used,
309
+ // so bg-[url('/path')] works inside class="..." (single quotes allowed in double-quoted attr)
310
+ const dqMatch = line.match(/(class\s*=\s*)(")([^"]*)"/)
311
+ const sqMatch = line.match(/(class\s*=\s*)(')([^']*)'/)
312
+ const match = dqMatch || sqMatch
353
313
  if (!match) return null
354
314
 
355
315
  const prefix = match[1]!
@@ -365,7 +325,7 @@ function replaceClassInAttribute(
365
325
  } else {
366
326
  classes.splice(idx, 1)
367
327
  }
368
- return line.replace(classAttrPattern, `${prefix}${quote}${classes.join(' ')}${quote}`)
328
+ return line.replace(match[0], `${prefix}${quote}${classes.join(' ')}${quote}`)
369
329
  }
370
330
 
371
331
  if (sourceLine) {
@@ -403,12 +363,23 @@ function appendClassToAttribute(
403
363
  newClass: string,
404
364
  sourceLine?: number,
405
365
  ): { success: true; content: string } | { success: false; error: string } {
406
- const appendPattern = /(class\s*=\s*["'])([^"']*)(["'])/
366
+ // Match class attribute with either quote, only excluding the actual quote used
367
+ // so bg-[url('/path')] works inside class="..."
368
+ const matchClassAttr = (line: string) => {
369
+ return line.match(/(class\s*=\s*")(([^"]*))(")/)
370
+ || line.match(/(class\s*=\s*')(([^']*))(')/)
371
+ }
407
372
 
408
- const doAppend = (_: string, open: string, classes: string, close: string) => {
373
+ const doAppendOnLine = (line: string): string | null => {
374
+ const match = matchClassAttr(line)
375
+ if (!match) return null
376
+ const open = match[1]!
377
+ const classes = match[2]!
378
+ const close = match[4]!
409
379
  const trimmed = classes.trimEnd()
410
380
  const separator = trimmed ? ' ' : ''
411
- return `${open}${trimmed}${separator}${escapeReplacement(newClass)}${close}`
381
+ const replacement = `${open}${trimmed}${separator}${escapeReplacement(newClass)}${close}`
382
+ return line.replace(match[0], replacement)
412
383
  }
413
384
 
414
385
  if (sourceLine) {
@@ -416,9 +387,9 @@ function appendClassToAttribute(
416
387
  const lineIndex = sourceLine - 1
417
388
 
418
389
  if (lineIndex >= 0 && lineIndex < lines.length) {
419
- const line = lines[lineIndex]!
420
- if (appendPattern.test(line)) {
421
- lines[lineIndex] = line.replace(appendPattern, doAppend)
390
+ const result = doAppendOnLine(lines[lineIndex]!)
391
+ if (result !== null) {
392
+ lines[lineIndex] = result
422
393
  return { success: true, content: lines.join('\n') }
423
394
  }
424
395
  return { success: false, error: `No class attribute found on line ${sourceLine}` }
@@ -426,10 +397,13 @@ function appendClassToAttribute(
426
397
  return { success: false, error: `Invalid source line ${sourceLine}` }
427
398
  }
428
399
 
429
- if (appendPattern.test(content)) {
430
- return {
431
- success: true,
432
- content: content.replace(appendPattern, doAppend),
400
+ // Fallback: find the first class attribute in the content
401
+ const lines = content.split('\n')
402
+ for (let i = 0; i < lines.length; i++) {
403
+ const result = doAppendOnLine(lines[i]!)
404
+ if (result !== null) {
405
+ lines[i] = result
406
+ return { success: true, content: lines.join('\n') }
433
407
  }
434
408
  }
435
409
  return { success: false, error: 'No class attribute found in source file' }
@@ -520,7 +494,7 @@ function applyAttributeChanges(
520
494
  return { content: newContent, appliedCount: attrApplied, failedChanges }
521
495
  }
522
496
 
523
- function applyTextChange(
497
+ export function applyTextChange(
524
498
  content: string,
525
499
  change: ChangePayload,
526
500
  manifest: CmsManifest,
@@ -530,7 +504,14 @@ function applyTextChange(
530
504
  let newText = htmlValue ?? newValue
531
505
  newText = resolveCmsPlaceholders(newText, manifest)
532
506
 
533
- if (!sourceSnippet || !originalValue) {
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) {
534
515
  if (change.attributeChanges && change.attributeChanges.length > 0) {
535
516
  return { success: true, content }
536
517
  }
@@ -541,19 +522,30 @@ function applyTextChange(
541
522
  return { success: false, error: 'Source snippet not found in file' }
542
523
  }
543
524
 
544
- // Replace originalValue with newText WITHIN the sourceSnippet
545
- const updatedSnippet = sourceSnippet.replace(originalValue, newText)
525
+ // Replace resolvedOriginal with newText WITHIN the sourceSnippet
526
+ const updatedSnippet = sourceSnippet.replace(resolvedOriginal, newText)
546
527
 
547
528
  if (updatedSnippet === sourceSnippet) {
548
- // originalValue wasn't found in snippet - try HTML entity handling
549
- const matchedText = findTextInSnippet(sourceSnippet, originalValue)
529
+ // resolvedOriginal wasn't found in snippet - try HTML entity handling
530
+ const matchedText = findTextInSnippet(sourceSnippet, resolvedOriginal)
550
531
  if (matchedText) {
551
532
  const updatedWithEntity = sourceSnippet.replace(matchedText, newText)
552
533
  return { success: true, content: content.replace(sourceSnippet, updatedWithEntity) }
553
534
  }
535
+ // Try inner content replacement for text spanning inline HTML elements
536
+ // (e.g., <h3>text part 1 <span class="...">text part 2</span></h3>)
537
+ const innerMatch = sourceSnippet.match(/^(\s*<(\w+)\b[^>]*>)([\s\S]*)(<\/\2>\s*)$/)
538
+ if (innerMatch) {
539
+ const [, openTag, , innerContent, closeTag] = innerMatch
540
+ const textOnly = innerContent!.replace(/<[^>]+>/g, '')
541
+ if (textOnly === resolvedOriginal) {
542
+ return { success: true, content: content.replace(sourceSnippet, openTag + newText + closeTag) }
543
+ }
544
+ }
545
+
554
546
  return {
555
547
  success: false,
556
- error: `Original text "${originalValue.substring(0, 50)}..." not found in source snippet`,
548
+ error: `Original text "${resolvedOriginal.substring(0, 50)}..." not found in source snippet`,
557
549
  }
558
550
  }
559
551
 
@@ -1,8 +1,8 @@
1
1
  import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
2
2
  import { processSeoFromHtml } from './seo-processor'
3
3
  import { enhanceManifestWithSourceSnippets } from './source-finder'
4
- import { extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
5
- import type { Attribute, ComponentInstance, ImageMetadata, ManifestEntry, PageSeoData, SeoOptions } from './types'
4
+ import { extractBackgroundImageClasses, extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
5
+ import type { Attribute, BackgroundImageMetadata, ComponentInstance, ImageMetadata, ManifestEntry, PageSeoData, SeoOptions } from './types'
6
6
  import { generateStableId } from './utils'
7
7
 
8
8
  /** Type for parsed HTML element nodes from node-html-parser */
@@ -612,6 +612,68 @@ export async function processHtml(
612
612
  })
613
613
  })
614
614
 
615
+ // Background image detection pass: mark elements with bg-[url()] classes
616
+ interface BgImageEntry {
617
+ metadata: BackgroundImageMetadata
618
+ sourceFile?: string
619
+ sourceLine?: number
620
+ }
621
+ const bgImageEntries = new Map<string, BgImageEntry>()
622
+ root.querySelectorAll('*').forEach((node) => {
623
+ // Skip already-marked elements
624
+ if (node.getAttribute(attributeName)) return
625
+
626
+ // Skip elements inside markdown wrapper
627
+ if (isInsideMarkdownWrapper(node)) return
628
+
629
+ const classAttr = node.getAttribute('class')
630
+ const bgMeta = extractBackgroundImageClasses(classAttr)
631
+ if (!bgMeta) return
632
+
633
+ // When skipMarkdownContent is true, only mark elements with source file attributes
634
+ if (skipMarkdownContent) {
635
+ let hasSourceAttr = false
636
+ let current: HTMLNode | null = node
637
+ while (current) {
638
+ if (current.getAttribute?.('data-astro-source-file')) {
639
+ hasSourceAttr = true
640
+ break
641
+ }
642
+ current = current.parentNode as HTMLNode | null
643
+ }
644
+ if (!hasSourceAttr) return
645
+ }
646
+
647
+ const id = getNextId()
648
+ node.setAttribute(attributeName, id)
649
+ node.setAttribute('data-cms-bg-img', 'true')
650
+
651
+ // Try to get source location from the element itself or ancestors
652
+ let sourceFile: string | undefined
653
+ let sourceLine: number | undefined
654
+ let current: HTMLNode | null = node
655
+ while (current && !sourceFile) {
656
+ const file = current.getAttribute?.('data-astro-source-file')
657
+ const line = current.getAttribute?.('data-astro-source-loc') || current.getAttribute?.('data-astro-source-line')
658
+ if (file) {
659
+ sourceFile = file
660
+ if (line) {
661
+ const lineNum = parseInt(line.split(':')[0] ?? '1', 10)
662
+ if (!Number.isNaN(lineNum)) {
663
+ sourceLine = lineNum
664
+ }
665
+ }
666
+ }
667
+ current = current.parentNode as HTMLNode | null
668
+ }
669
+
670
+ bgImageEntries.set(id, {
671
+ metadata: bgMeta,
672
+ sourceFile,
673
+ sourceLine,
674
+ })
675
+ })
676
+
615
677
  // Third pass: collect candidate text elements (don't mark yet)
616
678
  // We collect candidates first to filter out pure containers before marking
617
679
  interface TextCandidate {
@@ -826,12 +888,18 @@ export async function processHtml(
826
888
  const imageInfo = imageEntries.get(id)
827
889
  const isImage = !!imageInfo
828
890
 
891
+ // Check if this is a background image entry
892
+ const bgImageInfo = bgImageEntries.get(id)
893
+ // Also extract bg image classes fresh for elements marked for other reasons
894
+ const bgImageClassAttr = node.getAttribute('class')
895
+ const bgImageMetadata = bgImageInfo?.metadata ?? extractBackgroundImageClasses(bgImageClassAttr)
896
+
829
897
  // Check if this is the collection wrapper
830
898
  const isCollectionWrapper = id === collectionWrapperId
831
899
 
832
900
  const entryText = isImage ? (imageInfo.metadata.alt || imageInfo.metadata.src) : textWithPlaceholders.trim()
833
- // For images, use the source file we captured from ancestors if not in sourceLocationMap
834
- const entrySourcePath = sourceLocation?.file || imageInfo?.sourceFile || sourcePath
901
+ // For images/bg-images, use the source file we captured from ancestors if not in sourceLocationMap
902
+ const entrySourcePath = sourceLocation?.file || imageInfo?.sourceFile || bgImageInfo?.sourceFile || sourcePath
835
903
 
836
904
  // Generate stable ID based on content and context
837
905
  const stableId = generateStableId(tag, entryText, entrySourcePath)
@@ -854,7 +922,7 @@ export async function processHtml(
854
922
  html: htmlContent,
855
923
  sourcePath: entrySourcePath,
856
924
  childCmsIds: childCmsIds.length > 0 ? childCmsIds : undefined,
857
- sourceLine: sourceLocation?.line ?? imageInfo?.sourceLine,
925
+ sourceLine: sourceLocation?.line ?? imageInfo?.sourceLine ?? bgImageInfo?.sourceLine,
858
926
  sourceSnippet: undefined,
859
927
  variableName: undefined,
860
928
  parentComponentId,
@@ -866,6 +934,8 @@ export async function processHtml(
866
934
  stableId,
867
935
  // Image metadata for image entries
868
936
  imageMetadata: imageInfo?.metadata,
937
+ // Background image metadata for bg-[url()] elements
938
+ backgroundImage: bgImageMetadata,
869
939
  // Color and text style classes for buttons/styled elements
870
940
  colorClasses: allTrackedClasses,
871
941
  // All attributes with resolved values (isStatic will be updated later from source)