@nuasite/cms 0.1.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.
Files changed (269) hide show
  1. package/README.md +237 -0
  2. package/dist/src/build-processor.d.ts +20 -0
  3. package/dist/src/build-processor.d.ts.map +1 -0
  4. package/dist/src/collection-scanner.d.ts +6 -0
  5. package/dist/src/collection-scanner.d.ts.map +1 -0
  6. package/dist/src/component-registry.d.ts +63 -0
  7. package/dist/src/component-registry.d.ts.map +1 -0
  8. package/dist/src/config.d.ts +24 -0
  9. package/dist/src/config.d.ts.map +1 -0
  10. package/dist/src/dev-middleware.d.ts +20 -0
  11. package/dist/src/dev-middleware.d.ts.map +1 -0
  12. package/dist/src/editor/ai.d.ts +60 -0
  13. package/dist/src/editor/ai.d.ts.map +1 -0
  14. package/dist/src/editor/api.d.ts +140 -0
  15. package/dist/src/editor/api.d.ts.map +1 -0
  16. package/dist/src/editor/color-utils.d.ts +106 -0
  17. package/dist/src/editor/color-utils.d.ts.map +1 -0
  18. package/dist/src/editor/components/ai-chat.d.ts +11 -0
  19. package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
  20. package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
  21. package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
  22. package/dist/src/editor/components/attribute-editor.d.ts +5 -0
  23. package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
  24. package/dist/src/editor/components/block-editor.d.ts +12 -0
  25. package/dist/src/editor/components/block-editor.d.ts.map +1 -0
  26. package/dist/src/editor/components/collections-browser.d.ts +2 -0
  27. package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
  28. package/dist/src/editor/components/color-toolbar.d.ts +12 -0
  29. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
  30. package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
  31. package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
  32. package/dist/src/editor/components/create-page-modal.d.ts +2 -0
  33. package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
  34. package/dist/src/editor/components/editable-highlights.d.ts +9 -0
  35. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
  36. package/dist/src/editor/components/error-boundary.d.ts +32 -0
  37. package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
  38. package/dist/src/editor/components/fields.d.ts +75 -0
  39. package/dist/src/editor/components/fields.d.ts.map +1 -0
  40. package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
  41. package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
  42. package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
  43. package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
  44. package/dist/src/editor/components/image-overlay.d.ts +12 -0
  45. package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
  46. package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
  47. package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
  48. package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
  49. package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
  50. package/dist/src/editor/components/media-library.d.ts +2 -0
  51. package/dist/src/editor/components/media-library.d.ts.map +1 -0
  52. package/dist/src/editor/components/outline.d.ts +21 -0
  53. package/dist/src/editor/components/outline.d.ts.map +1 -0
  54. package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
  55. package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
  56. package/dist/src/editor/components/seo-editor.d.ts +2 -0
  57. package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
  58. package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
  59. package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
  60. package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
  61. package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
  62. package/dist/src/editor/components/toast/toast.d.ts +7 -0
  63. package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
  64. package/dist/src/editor/components/toast/types.d.ts +7 -0
  65. package/dist/src/editor/components/toast/types.d.ts.map +1 -0
  66. package/dist/src/editor/components/toolbar.d.ts +21 -0
  67. package/dist/src/editor/components/toolbar.d.ts.map +1 -0
  68. package/dist/src/editor/config.d.ts +4 -0
  69. package/dist/src/editor/config.d.ts.map +1 -0
  70. package/dist/src/editor/constants.d.ts +101 -0
  71. package/dist/src/editor/constants.d.ts.map +1 -0
  72. package/dist/src/editor/context.d.ts +14 -0
  73. package/dist/src/editor/context.d.ts.map +1 -0
  74. package/dist/src/editor/dom.d.ts +77 -0
  75. package/dist/src/editor/dom.d.ts.map +1 -0
  76. package/dist/src/editor/editor.d.ts +64 -0
  77. package/dist/src/editor/editor.d.ts.map +1 -0
  78. package/dist/src/editor/history.d.ts +20 -0
  79. package/dist/src/editor/history.d.ts.map +1 -0
  80. package/dist/src/editor/hooks/index.d.ts +14 -0
  81. package/dist/src/editor/hooks/index.d.ts.map +1 -0
  82. package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
  83. package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
  84. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
  85. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
  86. package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
  87. package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
  88. package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
  89. package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
  90. package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
  91. package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
  92. package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
  93. package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
  94. package/dist/src/editor/hooks/utils.d.ts +32 -0
  95. package/dist/src/editor/hooks/utils.d.ts.map +1 -0
  96. package/dist/src/editor/index.d.ts +12 -0
  97. package/dist/src/editor/index.d.ts.map +1 -0
  98. package/dist/src/editor/lib/cn.d.ts +3 -0
  99. package/dist/src/editor/lib/cn.d.ts.map +1 -0
  100. package/dist/src/editor/manifest.d.ts +19 -0
  101. package/dist/src/editor/manifest.d.ts.map +1 -0
  102. package/dist/src/editor/markdown-api.d.ts +36 -0
  103. package/dist/src/editor/markdown-api.d.ts.map +1 -0
  104. package/dist/src/editor/signals.d.ts +242 -0
  105. package/dist/src/editor/signals.d.ts.map +1 -0
  106. package/dist/src/editor/storage.d.ts +27 -0
  107. package/dist/src/editor/storage.d.ts.map +1 -0
  108. package/dist/src/editor/text-styling.d.ts +350 -0
  109. package/dist/src/editor/text-styling.d.ts.map +1 -0
  110. package/dist/src/editor/themes.d.ts +38 -0
  111. package/dist/src/editor/themes.d.ts.map +1 -0
  112. package/dist/src/editor/types.d.ts +454 -0
  113. package/dist/src/editor/types.d.ts.map +1 -0
  114. package/dist/src/error-collector.d.ts +56 -0
  115. package/dist/src/error-collector.d.ts.map +1 -0
  116. package/dist/src/handlers/component-ops.d.ts +34 -0
  117. package/dist/src/handlers/component-ops.d.ts.map +1 -0
  118. package/dist/src/handlers/markdown-ops.d.ts +41 -0
  119. package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
  120. package/dist/src/handlers/request-utils.d.ts +20 -0
  121. package/dist/src/handlers/request-utils.d.ts.map +1 -0
  122. package/dist/src/handlers/source-writer.d.ts +51 -0
  123. package/dist/src/handlers/source-writer.d.ts.map +1 -0
  124. package/dist/src/html-processor.d.ts +63 -0
  125. package/dist/src/html-processor.d.ts.map +1 -0
  126. package/dist/src/index.d.ts +41 -0
  127. package/dist/src/index.d.ts.map +1 -0
  128. package/dist/src/manifest-writer.d.ts +111 -0
  129. package/dist/src/manifest-writer.d.ts.map +1 -0
  130. package/dist/src/media/contember.d.ts +15 -0
  131. package/dist/src/media/contember.d.ts.map +1 -0
  132. package/dist/src/media/local.d.ts +9 -0
  133. package/dist/src/media/local.d.ts.map +1 -0
  134. package/dist/src/media/s3.d.ts +12 -0
  135. package/dist/src/media/s3.d.ts.map +1 -0
  136. package/dist/src/media/types.d.ts +40 -0
  137. package/dist/src/media/types.d.ts.map +1 -0
  138. package/dist/src/preview-generator.d.ts +19 -0
  139. package/dist/src/preview-generator.d.ts.map +1 -0
  140. package/dist/src/seo-processor.d.ts +23 -0
  141. package/dist/src/seo-processor.d.ts.map +1 -0
  142. package/dist/src/source-finder/ast-extractors.d.ts +35 -0
  143. package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
  144. package/dist/src/source-finder/ast-parser.d.ts +16 -0
  145. package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
  146. package/dist/src/source-finder/cache.d.ts +18 -0
  147. package/dist/src/source-finder/cache.d.ts.map +1 -0
  148. package/dist/src/source-finder/collection-finder.d.ts +29 -0
  149. package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
  150. package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
  151. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
  152. package/dist/src/source-finder/element-finder.d.ts +42 -0
  153. package/dist/src/source-finder/element-finder.d.ts.map +1 -0
  154. package/dist/src/source-finder/image-finder.d.ts +24 -0
  155. package/dist/src/source-finder/image-finder.d.ts.map +1 -0
  156. package/dist/src/source-finder/index.d.ts +9 -0
  157. package/dist/src/source-finder/index.d.ts.map +1 -0
  158. package/dist/src/source-finder/search-index.d.ts +27 -0
  159. package/dist/src/source-finder/search-index.d.ts.map +1 -0
  160. package/dist/src/source-finder/snippet-utils.d.ts +90 -0
  161. package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
  162. package/dist/src/source-finder/source-lookup.d.ts +16 -0
  163. package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
  164. package/dist/src/source-finder/types.d.ts +167 -0
  165. package/dist/src/source-finder/types.d.ts.map +1 -0
  166. package/dist/src/source-finder/variable-extraction.d.ts +37 -0
  167. package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
  168. package/dist/src/tailwind-colors.d.ts +54 -0
  169. package/dist/src/tailwind-colors.d.ts.map +1 -0
  170. package/dist/src/tsconfig.tsbuildinfo +1 -0
  171. package/dist/src/types.d.ts +367 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/utils.d.ts +61 -0
  174. package/dist/src/utils.d.ts.map +1 -0
  175. package/dist/src/vite-plugin.d.ts +14 -0
  176. package/dist/src/vite-plugin.d.ts.map +1 -0
  177. package/dist/types/tsconfig.tsbuildinfo +1 -0
  178. package/package.json +80 -0
  179. package/src/build-processor.ts +784 -0
  180. package/src/collection-scanner.ts +304 -0
  181. package/src/component-registry.ts +393 -0
  182. package/src/config.ts +74 -0
  183. package/src/dev-middleware.ts +525 -0
  184. package/src/dist/src/tsconfig.tsbuildinfo +1 -0
  185. package/src/editor/ai.ts +185 -0
  186. package/src/editor/api.ts +513 -0
  187. package/src/editor/color-utils.ts +556 -0
  188. package/src/editor/components/ai-chat.tsx +632 -0
  189. package/src/editor/components/ai-tooltip.tsx +179 -0
  190. package/src/editor/components/attribute-editor.tsx +596 -0
  191. package/src/editor/components/block-editor.tsx +546 -0
  192. package/src/editor/components/collections-browser.tsx +248 -0
  193. package/src/editor/components/color-toolbar.tsx +314 -0
  194. package/src/editor/components/confirm-dialog.tsx +69 -0
  195. package/src/editor/components/create-page-modal.tsx +163 -0
  196. package/src/editor/components/editable-highlights.tsx +260 -0
  197. package/src/editor/components/error-boundary.tsx +87 -0
  198. package/src/editor/components/fields.tsx +387 -0
  199. package/src/editor/components/frontmatter-fields.tsx +469 -0
  200. package/src/editor/components/highlight-overlay.ts +229 -0
  201. package/src/editor/components/image-overlay.tsx +230 -0
  202. package/src/editor/components/markdown-editor-overlay.tsx +505 -0
  203. package/src/editor/components/markdown-inline-editor.tsx +780 -0
  204. package/src/editor/components/media-library.tsx +297 -0
  205. package/src/editor/components/outline.tsx +402 -0
  206. package/src/editor/components/redirect-countdown.tsx +45 -0
  207. package/src/editor/components/seo-editor.tsx +498 -0
  208. package/src/editor/components/text-style-toolbar.tsx +362 -0
  209. package/src/editor/components/toast/toast-container.tsx +15 -0
  210. package/src/editor/components/toast/toast.tsx +49 -0
  211. package/src/editor/components/toast/types.ts +7 -0
  212. package/src/editor/components/toolbar.tsx +366 -0
  213. package/src/editor/config.ts +12 -0
  214. package/src/editor/constants.ts +106 -0
  215. package/src/editor/context.tsx +38 -0
  216. package/src/editor/dom.ts +357 -0
  217. package/src/editor/editor.ts +1510 -0
  218. package/src/editor/env.d.ts +4 -0
  219. package/src/editor/history.ts +355 -0
  220. package/src/editor/hooks/index.ts +19 -0
  221. package/src/editor/hooks/useAIHandlers.ts +345 -0
  222. package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
  223. package/src/editor/hooks/useElementDetection.ts +284 -0
  224. package/src/editor/hooks/useImageHoverDetection.ts +102 -0
  225. package/src/editor/hooks/useTextSelection.ts +187 -0
  226. package/src/editor/hooks/useTooltipState.ts +126 -0
  227. package/src/editor/hooks/utils.ts +101 -0
  228. package/src/editor/index.tsx +481 -0
  229. package/src/editor/lib/cn.ts +4 -0
  230. package/src/editor/manifest.ts +25 -0
  231. package/src/editor/markdown-api.ts +209 -0
  232. package/src/editor/signals.ts +1351 -0
  233. package/src/editor/storage.ts +266 -0
  234. package/src/editor/styles.css +465 -0
  235. package/src/editor/text-styling.ts +773 -0
  236. package/src/editor/themes.ts +210 -0
  237. package/src/editor/types.ts +591 -0
  238. package/src/error-collector.ts +106 -0
  239. package/src/handlers/component-ops.ts +463 -0
  240. package/src/handlers/markdown-ops.ts +202 -0
  241. package/src/handlers/request-utils.ts +151 -0
  242. package/src/handlers/source-writer.ts +649 -0
  243. package/src/html-processor.ts +1108 -0
  244. package/src/index.ts +284 -0
  245. package/src/manifest-writer.ts +371 -0
  246. package/src/media/contember.ts +84 -0
  247. package/src/media/local.ts +114 -0
  248. package/src/media/s3.ts +133 -0
  249. package/src/media/types.ts +33 -0
  250. package/src/preview-generator.ts +293 -0
  251. package/src/seo-processor.ts +567 -0
  252. package/src/source-finder/ast-extractors.ts +185 -0
  253. package/src/source-finder/ast-parser.ts +150 -0
  254. package/src/source-finder/cache.ts +76 -0
  255. package/src/source-finder/collection-finder.ts +335 -0
  256. package/src/source-finder/cross-file-tracker.ts +741 -0
  257. package/src/source-finder/element-finder.ts +387 -0
  258. package/src/source-finder/image-finder.ts +283 -0
  259. package/src/source-finder/index.ts +37 -0
  260. package/src/source-finder/search-index.ts +525 -0
  261. package/src/source-finder/snippet-utils.ts +668 -0
  262. package/src/source-finder/source-lookup.ts +200 -0
  263. package/src/source-finder/types.ts +210 -0
  264. package/src/source-finder/variable-extraction.ts +406 -0
  265. package/src/tailwind-colors.ts +874 -0
  266. package/src/tsconfig.json +25 -0
  267. package/src/types.ts +406 -0
  268. package/src/utils.ts +186 -0
  269. package/src/vite-plugin.ts +42 -0
@@ -0,0 +1,649 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { getProjectRoot } from '../config'
4
+ import type { ManifestWriter } from '../manifest-writer'
5
+ import type { CmsManifest, ManifestEntry } from '../types'
6
+ import { acquireFileLock, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
7
+
8
+ export interface ColorChangePayload {
9
+ oldClass: string
10
+ newClass: string
11
+ type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText'
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
+ export interface SaveBatchResponse {
52
+ updated: number
53
+ errors?: Array<{ cmsId: string; error: string }>
54
+ }
55
+
56
+ export async function handleUpdate(
57
+ request: SaveBatchRequest,
58
+ manifestWriter: ManifestWriter,
59
+ ): Promise<SaveBatchResponse> {
60
+ const { changes, meta } = request
61
+ const errors: Array<{ cmsId: string; error: string }> = []
62
+ let updated = 0
63
+
64
+ // Get the manifest for the page being edited
65
+ const pagePath = normalizePagePath(meta.url)
66
+ const pageData = manifestWriter.getPageManifest(pagePath)
67
+ const manifest: CmsManifest = pageData
68
+ ? {
69
+ entries: pageData.entries,
70
+ components: pageData.components,
71
+ componentDefinitions: manifestWriter.getComponentDefinitions(),
72
+ }
73
+ : manifestWriter.getGlobalManifest()
74
+
75
+ // Group changes by source file
76
+ const changesByFile: Record<string, ChangePayload[]> = {}
77
+ for (const change of changes) {
78
+ const filePath = change.sourcePath
79
+ if (!filePath) {
80
+ errors.push({ cmsId: change.cmsId, error: 'No file path in change payload' })
81
+ continue
82
+ }
83
+ if (!changesByFile[filePath]) {
84
+ changesByFile[filePath] = []
85
+ }
86
+ changesByFile[filePath]!.push(change)
87
+ }
88
+
89
+ const projectRoot = getProjectRoot()
90
+
91
+ for (const [filePath, fileChanges] of Object.entries(changesByFile)) {
92
+ try {
93
+ const fullPath = resolveAndValidatePath(filePath)
94
+ const release = await acquireFileLock(fullPath)
95
+ try {
96
+ const currentContent = await fs.readFile(fullPath, 'utf-8')
97
+
98
+ const { newContent, appliedCount, failedChanges } = applyChanges(
99
+ currentContent,
100
+ fileChanges,
101
+ manifest,
102
+ )
103
+
104
+ if (failedChanges.length > 0) {
105
+ errors.push(...failedChanges)
106
+ }
107
+
108
+ if (appliedCount > 0 && newContent !== currentContent) {
109
+ await fs.writeFile(fullPath, newContent, 'utf-8')
110
+ updated += appliedCount
111
+ }
112
+ } finally {
113
+ release()
114
+ }
115
+ } catch (error) {
116
+ const errorMessage = error instanceof Error ? error.message : String(error)
117
+ errors.push(
118
+ ...fileChanges.map((c) => ({ cmsId: c.cmsId, error: errorMessage })),
119
+ )
120
+ }
121
+ }
122
+
123
+ return {
124
+ updated,
125
+ errors: errors.length > 0 ? errors : undefined,
126
+ }
127
+ }
128
+
129
+ function applyChanges(
130
+ content: string,
131
+ changes: ChangePayload[],
132
+ manifest: CmsManifest,
133
+ ): {
134
+ newContent: string
135
+ appliedCount: number
136
+ failedChanges: Array<{ cmsId: string; error: string }>
137
+ } {
138
+ let newContent = content
139
+ let appliedCount = 0
140
+ const failedChanges: Array<{ cmsId: string; error: string }> = []
141
+
142
+ // Sort changes by source line descending to prevent offset shifts
143
+ const sortedChanges = [...changes].sort(
144
+ (a, b) => (b.sourceLine ?? 0) - (a.sourceLine ?? 0),
145
+ )
146
+
147
+ for (const change of sortedChanges) {
148
+ // Handle image changes
149
+ if (change.imageChange) {
150
+ const result = applyImageChange(newContent, change)
151
+ if (result.success) {
152
+ newContent = result.content
153
+ appliedCount++
154
+ } else {
155
+ failedChanges.push({ cmsId: change.cmsId, error: result.error })
156
+ }
157
+ continue
158
+ }
159
+
160
+ // Handle color class changes
161
+ if (change.colorChange) {
162
+ const result = applyColorChange(newContent, change)
163
+ if (result.success) {
164
+ newContent = result.content
165
+ appliedCount++
166
+ } else {
167
+ failedChanges.push({ cmsId: change.cmsId, error: result.error })
168
+ }
169
+ continue
170
+ }
171
+
172
+ // Handle attribute changes
173
+ if (change.attributeChanges && change.attributeChanges.length > 0) {
174
+ const result = applyAttributeChanges(newContent, change)
175
+ if (result.appliedCount > 0) {
176
+ newContent = result.content
177
+ appliedCount++
178
+ }
179
+ failedChanges.push(...result.failedChanges)
180
+ continue
181
+ }
182
+
183
+ // Text content change
184
+ const result = applyTextChange(newContent, change, manifest)
185
+ if (result.success) {
186
+ newContent = result.content
187
+ appliedCount++
188
+ } else {
189
+ failedChanges.push({ cmsId: change.cmsId, error: result.error })
190
+ }
191
+ }
192
+
193
+ return { newContent, appliedCount, failedChanges }
194
+ }
195
+
196
+ function applyImageChange(
197
+ content: string,
198
+ change: ChangePayload,
199
+ ): { success: true; content: string } | { success: false; error: string } {
200
+ const { newSrc, newAlt } = change.imageChange!
201
+ const originalSrc = change.originalValue
202
+
203
+ if (!originalSrc) {
204
+ return { success: false, error: 'No original image src in change payload' }
205
+ }
206
+
207
+ const srcCandidates = [originalSrc]
208
+ if (originalSrc.startsWith('http://') || originalSrc.startsWith('https://')) {
209
+ try {
210
+ const parsedUrl = new URL(originalSrc)
211
+ if (parsedUrl.pathname !== originalSrc) {
212
+ srcCandidates.push(parsedUrl.pathname)
213
+ }
214
+ } catch {
215
+ // URL parsing failed, just use original value
216
+ }
217
+ }
218
+
219
+ // Extract the authored src from the source snippet if available
220
+ // This handles cases where an Image component transforms the URL (e.g., CDN optimization)
221
+ // so the rendered src differs from the authored src in the source file
222
+ if (change.sourceSnippet) {
223
+ const snippetSrcMatch = change.sourceSnippet.match(/src\s*=\s*"([^"]+)"/) || change.sourceSnippet.match(/src\s*=\s*'([^']+)'/)
224
+ if (snippetSrcMatch?.[1] && !srcCandidates.includes(snippetSrcMatch[1])) {
225
+ srcCandidates.push(snippetSrcMatch[1])
226
+ }
227
+ }
228
+
229
+ let newContent = content
230
+ let replacedIndex = -1
231
+ for (const srcToFind of srcCandidates) {
232
+ // Use non-global patterns to replace only the first occurrence
233
+ const srcPatternDouble = new RegExp(`src="${escapeRegExp(srcToFind)}"`)
234
+ const srcPatternSingle = new RegExp(`src='${escapeRegExp(srcToFind)}'`)
235
+
236
+ const escapedNewSrc = escapeReplacement(newSrc)
237
+ const doubleMatch = newContent.match(srcPatternDouble)
238
+ if (doubleMatch && doubleMatch.index !== undefined) {
239
+ replacedIndex = doubleMatch.index
240
+ newContent = newContent.slice(0, replacedIndex)
241
+ + newContent.slice(replacedIndex).replace(srcPatternDouble, `src="${escapedNewSrc}"`)
242
+ break
243
+ }
244
+ const singleMatch = newContent.match(srcPatternSingle)
245
+ if (singleMatch && singleMatch.index !== undefined) {
246
+ replacedIndex = singleMatch.index
247
+ newContent = newContent.slice(0, replacedIndex)
248
+ + newContent.slice(replacedIndex).replace(srcPatternSingle, `src='${escapedNewSrc}'`)
249
+ break
250
+ }
251
+ }
252
+
253
+ // Fallback: if literal src not found, try to find an expression-based src attribute
254
+ // near the source line (handles src={variable}, src={obj.prop}, etc.)
255
+ if (replacedIndex < 0 && change.sourceLine > 0) {
256
+ const lines = newContent.split('\n')
257
+ const targetLineIdx = change.sourceLine - 1
258
+
259
+ // Search a region around the source line for an <img with src attribute
260
+ const regionStart = Math.max(0, targetLineIdx - 3)
261
+ const regionEnd = Math.min(lines.length, targetLineIdx + 10)
262
+ const regionLines = lines.slice(regionStart, regionEnd)
263
+ const regionText = regionLines.join('\n')
264
+
265
+ // Verify we're in an img or Image component context before replacing
266
+ if (/<img\b/i.test(regionText) || /<Image\b/.test(regionText)) {
267
+ // Match src attribute with expression value: src={...} (handling balanced braces)
268
+ const exprMatch = findExpressionSrcAttribute(regionText)
269
+ if (exprMatch) {
270
+ const regionOffset = regionStart > 0
271
+ ? lines.slice(0, regionStart).join('\n').length + 1
272
+ : 0
273
+ const absoluteIndex = regionOffset + exprMatch.index
274
+
275
+ const escapedNewSrc = escapeReplacement(newSrc)
276
+ newContent = newContent.slice(0, absoluteIndex)
277
+ + `src="${escapedNewSrc}"`
278
+ + newContent.slice(absoluteIndex + exprMatch.length)
279
+ replacedIndex = absoluteIndex
280
+ }
281
+ }
282
+ }
283
+
284
+ if (replacedIndex < 0) {
285
+ return { success: false, error: `Image src not found in source file: ${originalSrc}` }
286
+ }
287
+
288
+ // Replace alt only in the same img tag context (within ~500 chars around the replaced src)
289
+ if (newAlt !== undefined) {
290
+ const searchStart = Math.max(0, replacedIndex - 200)
291
+ const searchEnd = Math.min(newContent.length, replacedIndex + 300)
292
+ const region = newContent.slice(searchStart, searchEnd)
293
+
294
+ const altPatternDouble = /alt="[^"]*"/
295
+ const altPatternSingle = /alt='[^']*'/
296
+ // Also match expression-based alt: alt={...}
297
+ const altPatternExpr = /alt\s*=\s*\{[^}]*\}/
298
+
299
+ const altDoubleMatch = region.match(altPatternDouble)
300
+ const altSingleMatch = region.match(altPatternSingle)
301
+ const altExprMatch = region.match(altPatternExpr)
302
+
303
+ // Pick the first match found (string literals preferred over expressions)
304
+ const altMatch = altDoubleMatch ?? altSingleMatch ?? altExprMatch
305
+ const altQuote = altDoubleMatch ? '"' : altSingleMatch ? "'" : '"'
306
+
307
+ if (altMatch && altMatch.index !== undefined) {
308
+ const altAbsoluteIndex = searchStart + altMatch.index
309
+ // Escape quotes in alt text matching the quote style used
310
+ const escapedAlt = altQuote === '"'
311
+ ? newAlt.replace(/"/g, '&quot;')
312
+ : newAlt.replace(/'/g, '&#39;')
313
+ newContent = newContent.slice(0, altAbsoluteIndex)
314
+ + `alt=${altQuote}${escapedAlt}${altQuote}`
315
+ + newContent.slice(altAbsoluteIndex + altMatch[0].length)
316
+ }
317
+ }
318
+
319
+ return { success: true, content: newContent }
320
+ }
321
+
322
+ function applyColorChange(
323
+ content: string,
324
+ change: ChangePayload,
325
+ ): { 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)
328
+ // over the outer change.sourceLine (may point to a data declaration)
329
+ const sourceLine = change.colorChange!.sourceLine ?? change.sourceLine
330
+
331
+ // When oldClass is empty, we're adding a new color class (not replacing)
332
+ if (!oldClass) {
333
+ return appendClassToAttribute(content, newClass, sourceLine)
334
+ }
335
+
336
+ return replaceClassInAttribute(content, oldClass, newClass, sourceLine)
337
+ }
338
+
339
+ /**
340
+ * Replace an existing class within a class attribute by splitting on whitespace.
341
+ * This avoids \b word-boundary issues (e.g., \b matching `:` in `hover:bg-red-500`).
342
+ */
343
+ function replaceClassInAttribute(
344
+ content: string,
345
+ oldClass: string,
346
+ newClass: string,
347
+ sourceLine?: number,
348
+ ): { success: true; content: string } | { success: false; error: string } {
349
+ const classAttrPattern = /(class\s*=\s*)(["'])([^"']*)\2/
350
+
351
+ const replaceOnLine = (line: string): string | null => {
352
+ const match = line.match(classAttrPattern)
353
+ if (!match) return null
354
+
355
+ const prefix = match[1]!
356
+ const quote = match[2]!
357
+ const classContent = match[3]!
358
+
359
+ const classes = classContent.split(/\s+/).filter(Boolean)
360
+ const idx = classes.indexOf(oldClass)
361
+ if (idx === -1) return null
362
+
363
+ classes[idx] = newClass
364
+ return line.replace(classAttrPattern, `${prefix}${quote}${classes.join(' ')}${quote}`)
365
+ }
366
+
367
+ if (sourceLine) {
368
+ const lines = content.split('\n')
369
+ const lineIndex = sourceLine - 1
370
+
371
+ if (lineIndex >= 0 && lineIndex < lines.length) {
372
+ const result = replaceOnLine(lines[lineIndex]!)
373
+ if (result !== null) {
374
+ lines[lineIndex] = result
375
+ return { success: true, content: lines.join('\n') }
376
+ }
377
+ return { success: false, error: `Color class '${oldClass}' not found on line ${sourceLine}` }
378
+ }
379
+ return { success: false, error: `Invalid source line ${sourceLine}` }
380
+ }
381
+
382
+ // Fallback: find the first class attribute in the content that contains oldClass
383
+ const lines = content.split('\n')
384
+ for (let i = 0; i < lines.length; i++) {
385
+ const result = replaceOnLine(lines[i]!)
386
+ if (result !== null) {
387
+ lines[i] = result
388
+ return { success: true, content: lines.join('\n') }
389
+ }
390
+ }
391
+ return { success: false, error: `Color class '${oldClass}' not found in source file` }
392
+ }
393
+
394
+ /**
395
+ * Append a new class to an existing class attribute.
396
+ */
397
+ function appendClassToAttribute(
398
+ content: string,
399
+ newClass: string,
400
+ sourceLine?: number,
401
+ ): { success: true; content: string } | { success: false; error: string } {
402
+ const appendPattern = /(class\s*=\s*["'])([^"']*)(["'])/
403
+
404
+ const doAppend = (_: string, open: string, classes: string, close: string) => {
405
+ const trimmed = classes.trimEnd()
406
+ const separator = trimmed ? ' ' : ''
407
+ return `${open}${trimmed}${separator}${escapeReplacement(newClass)}${close}`
408
+ }
409
+
410
+ if (sourceLine) {
411
+ const lines = content.split('\n')
412
+ const lineIndex = sourceLine - 1
413
+
414
+ if (lineIndex >= 0 && lineIndex < lines.length) {
415
+ const line = lines[lineIndex]!
416
+ if (appendPattern.test(line)) {
417
+ lines[lineIndex] = line.replace(appendPattern, doAppend)
418
+ return { success: true, content: lines.join('\n') }
419
+ }
420
+ return { success: false, error: `No class attribute found on line ${sourceLine}` }
421
+ }
422
+ return { success: false, error: `Invalid source line ${sourceLine}` }
423
+ }
424
+
425
+ if (appendPattern.test(content)) {
426
+ return {
427
+ success: true,
428
+ content: content.replace(appendPattern, doAppend),
429
+ }
430
+ }
431
+ return { success: false, error: 'No class attribute found in source file' }
432
+ }
433
+
434
+ function applyAttributeChanges(
435
+ content: string,
436
+ change: ChangePayload,
437
+ ): {
438
+ content: string
439
+ appliedCount: number
440
+ failedChanges: Array<{ cmsId: string; error: string }>
441
+ } {
442
+ let newContent = content
443
+ let attrApplied = 0
444
+ const failedChanges: Array<{ cmsId: string; error: string }> = []
445
+
446
+ for (const attrChange of change.attributeChanges!) {
447
+ const { attributeName, oldValue: attrOldValue, newValue: attrNewValue } = attrChange
448
+ if (attrOldValue === undefined || attrNewValue === undefined) {
449
+ failedChanges.push({
450
+ cmsId: change.cmsId,
451
+ error: `Missing oldValue or newValue for attribute '${attributeName}'`,
452
+ })
453
+ continue
454
+ }
455
+
456
+ const targetLine = attrChange.sourceLine ?? change.sourceLine
457
+ if (targetLine) {
458
+ const lines = newContent.split('\n')
459
+ const lineIndex = targetLine - 1
460
+
461
+ if (lineIndex >= 0 && lineIndex < lines.length) {
462
+ const line = lines[lineIndex]!
463
+ const doubleQuotePattern = new RegExp(
464
+ `(${escapeRegExp(attributeName)}\\s*=\\s*)"(${escapeRegExp(attrOldValue)})"`,
465
+ )
466
+ const singleQuotePattern = new RegExp(
467
+ `(${escapeRegExp(attributeName)}\\s*=\\s*)'(${escapeRegExp(attrOldValue)})'`,
468
+ )
469
+
470
+ const safeNewValue = escapeReplacement(attrNewValue)
471
+ if (doubleQuotePattern.test(line)) {
472
+ lines[lineIndex] = line.replace(doubleQuotePattern, `$1"${safeNewValue}"`)
473
+ newContent = lines.join('\n')
474
+ attrApplied++
475
+ } else if (singleQuotePattern.test(line)) {
476
+ lines[lineIndex] = line.replace(singleQuotePattern, `$1'${safeNewValue}'`)
477
+ newContent = lines.join('\n')
478
+ attrApplied++
479
+ } else {
480
+ failedChanges.push({
481
+ cmsId: change.cmsId,
482
+ error: `Attribute '${attributeName}="${attrOldValue}"' not found on line ${targetLine}`,
483
+ })
484
+ }
485
+ } else {
486
+ failedChanges.push({
487
+ cmsId: change.cmsId,
488
+ error: `Invalid source line ${targetLine} for attribute '${attributeName}'`,
489
+ })
490
+ }
491
+ } else {
492
+ // Fallback: replace first occurrence in the whole file
493
+ const doubleQuotePattern = new RegExp(
494
+ `(${escapeRegExp(attributeName)}\\s*=\\s*)"(${escapeRegExp(attrOldValue)})"`,
495
+ )
496
+ const singleQuotePattern = new RegExp(
497
+ `(${escapeRegExp(attributeName)}\\s*=\\s*)'(${escapeRegExp(attrOldValue)})'`,
498
+ )
499
+
500
+ const safeNewValue = escapeReplacement(attrNewValue)
501
+ if (doubleQuotePattern.test(newContent)) {
502
+ newContent = newContent.replace(doubleQuotePattern, `$1"${safeNewValue}"`)
503
+ attrApplied++
504
+ } else if (singleQuotePattern.test(newContent)) {
505
+ newContent = newContent.replace(singleQuotePattern, `$1'${safeNewValue}'`)
506
+ attrApplied++
507
+ } else {
508
+ failedChanges.push({
509
+ cmsId: change.cmsId,
510
+ error: `Attribute '${attributeName}="${attrOldValue}"' not found in source file`,
511
+ })
512
+ }
513
+ }
514
+ }
515
+
516
+ return { content: newContent, appliedCount: attrApplied, failedChanges }
517
+ }
518
+
519
+ function applyTextChange(
520
+ content: string,
521
+ change: ChangePayload,
522
+ manifest: CmsManifest,
523
+ ): { success: true; content: string } | { success: false; error: string } {
524
+ const { sourceSnippet, originalValue, newValue, htmlValue } = change
525
+
526
+ let newText = htmlValue ?? newValue
527
+ newText = resolveCmsPlaceholders(newText, manifest)
528
+
529
+ if (!sourceSnippet || !originalValue) {
530
+ if (change.attributeChanges && change.attributeChanges.length > 0) {
531
+ return { success: true, content }
532
+ }
533
+ return { success: false, error: 'Missing sourceSnippet or originalValue in change payload' }
534
+ }
535
+
536
+ if (!content.includes(sourceSnippet)) {
537
+ return { success: false, error: 'Source snippet not found in file' }
538
+ }
539
+
540
+ // Replace originalValue with newText WITHIN the sourceSnippet
541
+ const updatedSnippet = sourceSnippet.replace(originalValue, newText)
542
+
543
+ if (updatedSnippet === sourceSnippet) {
544
+ // originalValue wasn't found in snippet - try HTML entity handling
545
+ const matchedText = findTextInSnippet(sourceSnippet, originalValue)
546
+ if (matchedText) {
547
+ const updatedWithEntity = sourceSnippet.replace(matchedText, newText)
548
+ return { success: true, content: content.replace(sourceSnippet, updatedWithEntity) }
549
+ }
550
+ return {
551
+ success: false,
552
+ error: `Original text "${originalValue.substring(0, 50)}..." not found in source snippet`,
553
+ }
554
+ }
555
+
556
+ return { success: true, content: content.replace(sourceSnippet, updatedSnippet) }
557
+ }
558
+
559
+ /**
560
+ * Find the original text within a source snippet, accounting for HTML entities.
561
+ */
562
+ function findTextInSnippet(snippet: string, decodedText: string): string | null {
563
+ if (snippet.includes(decodedText)) {
564
+ return decodedText
565
+ }
566
+
567
+ const entityMap: Array<[string, string]> = [
568
+ // & must be first: other entities contain & which would get double-expanded
569
+ ['&', '&amp;'],
570
+ [' ', '&nbsp;'],
571
+ [' ', '&#160;'],
572
+ ['<', '&lt;'],
573
+ ['>', '&gt;'],
574
+ ['"', '&quot;'],
575
+ ["'", '&#39;'],
576
+ ["'", '&apos;'],
577
+ ]
578
+
579
+ let pattern = escapeRegExp(decodedText)
580
+ for (const [char, entity] of entityMap) {
581
+ const escapedChar = escapeRegExp(char)
582
+ const escapedEntity = escapeRegExp(entity)
583
+ pattern = pattern.replace(new RegExp(escapedChar, 'g'), `(?:${escapedChar}|${escapedEntity})`)
584
+ }
585
+
586
+ const regex = new RegExp(pattern)
587
+ const match = snippet.match(regex)
588
+ if (match) return match[0]
589
+
590
+ // Try matching with <br> tags stripped from snippet
591
+ const chars = [...decodedText].map((ch) => escapeRegExp(ch))
592
+ const brAwarePattern = chars.join('(?:<br\\s*\\/?>)*')
593
+ const brRegex = new RegExp(brAwarePattern)
594
+ const brMatch = snippet.match(brRegex)
595
+
596
+ return brMatch && brMatch[0] !== decodedText ? brMatch[0] : null
597
+ }
598
+
599
+ /**
600
+ * Resolve CMS placeholders like {{cms:cms-96}} in text.
601
+ */
602
+ function resolveCmsPlaceholders(text: string, manifest: CmsManifest): string {
603
+ const placeholderPattern = /\{\{cms:([^}]+)\}\}/g
604
+
605
+ return text.replace(placeholderPattern, (match, cmsId: string) => {
606
+ const childEntry: ManifestEntry | undefined = manifest.entries[cmsId]
607
+ if (!childEntry) {
608
+ return match
609
+ }
610
+ if (childEntry.sourceSnippet) {
611
+ return childEntry.sourceSnippet
612
+ }
613
+ return childEntry.html ?? childEntry.text ?? match
614
+ })
615
+ }
616
+
617
+ /**
618
+ * Find a src attribute with expression value (e.g., src={variable}) in text.
619
+ * Handles balanced braces for nested expressions.
620
+ * Returns the match with index and length, or null if not found.
621
+ */
622
+ function findExpressionSrcAttribute(text: string): { index: number; length: number } | null {
623
+ // Find 'src=' followed by '{'
624
+ const srcExprStart = /src\s*=\s*\{/
625
+ const match = text.match(srcExprStart)
626
+ if (!match || match.index === undefined) return null
627
+
628
+ // Find the matching closing brace (handle nesting)
629
+ const braceStart = match.index + match[0].length - 1 // index of '{'
630
+ let depth = 1
631
+ let i = braceStart + 1
632
+ while (i < text.length && depth > 0) {
633
+ if (text[i] === '{') depth++
634
+ else if (text[i] === '}') depth--
635
+ i++
636
+ }
637
+
638
+ if (depth !== 0) return null // Unbalanced braces
639
+
640
+ return {
641
+ index: match.index,
642
+ length: i - match.index,
643
+ }
644
+ }
645
+
646
+ function escapeRegExp(string: string): string {
647
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
648
+ }
649
+