@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,1510 @@
1
+ import type { Attribute } from './types'
2
+ import { fetchManifest, getDeploymentStatus, getMarkdownContent, saveBatchChanges } from './api'
3
+ import { CSS, TIMING } from './constants'
4
+ import { clearHistory, isApplyingUndoRedo, recordChange, recordTextChange } from './history'
5
+ import {
6
+ cleanupHighlightSystem,
7
+ disableAllInteractiveElements,
8
+ enableAllInteractiveElements,
9
+ findInnermostCmsElement,
10
+ getAllCmsElements,
11
+ getChildCmsElements,
12
+ getEditableHtmlFromElement,
13
+ getEditableTextFromElement,
14
+ initHighlightSystem,
15
+ logDebug,
16
+ makeElementEditable,
17
+ makeElementNonEditable,
18
+ } from './dom'
19
+ import { getManifestEntryCount, hasManifestEntry } from './manifest'
20
+ import * as signals from './signals'
21
+ import {
22
+ clearAllEditsFromStorage,
23
+ loadAttributeEditsFromStorage,
24
+ loadColorEditsFromStorage,
25
+ loadEditsFromStorage,
26
+ loadImageEditsFromStorage,
27
+ loadPendingEntryNavigation,
28
+ saveAttributeEditsToStorage,
29
+ saveColorEditsToStorage,
30
+ saveEditsToStorage,
31
+ saveImageEditsToStorage,
32
+ } from './storage'
33
+ import type { AttributeChangePayload, ChangePayload, CmsConfig, DeploymentStatusResponse, ManifestEntry, SavedAttributeEdit } from './types'
34
+
35
+ // CSS attribute for markdown content elements
36
+ const MARKDOWN_ATTRIBUTE = 'data-cms-markdown'
37
+ // CSS attribute for image elements
38
+ const IMAGE_ATTRIBUTE = 'data-cms-img'
39
+
40
+ /**
41
+ * Inline HTML elements that indicate styled/formatted content.
42
+ * When an element contains these, we need to preserve the HTML structure.
43
+ */
44
+ const INLINE_STYLE_ELEMENTS = [
45
+ 'strong',
46
+ 'b',
47
+ 'em',
48
+ 'i',
49
+ 'u',
50
+ 's',
51
+ 'strike',
52
+ 'del',
53
+ 'ins',
54
+ 'mark',
55
+ 'small',
56
+ 'sub',
57
+ 'sup',
58
+ 'abbr',
59
+ 'cite',
60
+ 'code',
61
+ 'kbd',
62
+ 'samp',
63
+ 'var',
64
+ 'time',
65
+ 'dfn',
66
+ 'q',
67
+ ]
68
+
69
+ /**
70
+ * Check if an element contains styled/formatted content (inline text styling).
71
+ * This includes:
72
+ * - Spans with data-cms-styled attribute (Tailwind styled)
73
+ * - Inline HTML elements (strong, b, em, i, etc.)
74
+ */
75
+ function hasStyledContent(el: HTMLElement): boolean {
76
+ // Check for spans with explicit styling attribute
77
+ if (el.querySelector('[data-cms-styled]') !== null) {
78
+ return true
79
+ }
80
+
81
+ // Check for inline HTML style elements
82
+ const selector = INLINE_STYLE_ELEMENTS.join(', ')
83
+ return el.querySelector(selector) !== null
84
+ }
85
+
86
+ /**
87
+ * Start edit mode - enables inline editing on all CMS elements.
88
+ * Uses signals for state management.
89
+ */
90
+ export async function startEditMode(
91
+ config: CmsConfig,
92
+ onStateChange?: () => void,
93
+ ): Promise<void> {
94
+ signals.setEditing(true)
95
+ disableAllInteractiveElements()
96
+ initHighlightSystem()
97
+ onStateChange?.()
98
+
99
+ try {
100
+ const manifest = await fetchManifest()
101
+ signals.setManifest(manifest)
102
+ const entryCount = getManifestEntryCount(manifest)
103
+ logDebug(config.debug, 'Loaded manifest with', entryCount, 'entries')
104
+ } catch (err) {
105
+ console.error('[CMS] Failed to load manifest:', err)
106
+ return
107
+ }
108
+
109
+ const savedEdits = loadEditsFromStorage()
110
+ const savedImageEdits = loadImageEditsFromStorage()
111
+ const savedColorEdits = loadColorEditsFromStorage()
112
+ const savedAttributeEdits = loadAttributeEditsFromStorage()
113
+ const currentManifest = signals.manifest.value
114
+
115
+ getAllCmsElements().forEach(el => {
116
+ const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
117
+ if (!cmsId) return
118
+
119
+ // Skip component elements - they should not be contentEditable
120
+ // Components are marked with data-cms-component-id and are block-level editable
121
+ if (el.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) {
122
+ logDebug(config.debug, 'Skipping component element:', cmsId)
123
+ makeElementNonEditable(el)
124
+ return
125
+ }
126
+
127
+ if (!hasManifestEntry(currentManifest, cmsId)) {
128
+ logDebug(config.debug, 'Skipping element not in manifest:', cmsId)
129
+ makeElementNonEditable(el)
130
+ return
131
+ }
132
+
133
+ // Check if this is a markdown content element
134
+ // Markdown elements use WYSIWYG editing instead of contentEditable
135
+ if (el.hasAttribute(MARKDOWN_ATTRIBUTE)) {
136
+ logDebug(config.debug, 'Markdown element detected:', cmsId)
137
+ makeElementNonEditable(el)
138
+ // Add click handler for markdown elements to open the editor
139
+ setupMarkdownClickHandler(config, el, cmsId, onStateChange)
140
+ return
141
+ }
142
+
143
+ // Check if this is an image element
144
+ // Image elements open the media library for replacement
145
+ if (el.hasAttribute(IMAGE_ATTRIBUTE)) {
146
+ logDebug(config.debug, 'Image element detected:', cmsId)
147
+ makeElementNonEditable(el)
148
+ setupImageClickHandler(config, el as HTMLImageElement, cmsId, savedImageEdits[cmsId], onStateChange)
149
+ return
150
+ }
151
+
152
+ makeElementEditable(el)
153
+
154
+ // Suppress browser native contentEditable undo/redo (we handle it ourselves)
155
+ el.addEventListener('beforeinput', (e) => {
156
+ if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
157
+ e.preventDefault()
158
+ }
159
+ })
160
+
161
+ // Setup color tracking for elements with colorClasses in manifest
162
+ setupColorTracking(config, el, cmsId, savedColorEdits[cmsId])
163
+
164
+ // Setup attribute tracking for elements with editable attributes in manifest
165
+ setupAttributeTracking(config, el, cmsId, savedAttributeEdits[cmsId])
166
+
167
+ if (!signals.pendingChanges.value.has(cmsId)) {
168
+ const originalHTML = el.innerHTML
169
+ const originalText = getEditableTextFromElement(el)
170
+
171
+ logDebug(config.debug, 'Setting up element:', cmsId, 'originalText:', originalText)
172
+
173
+ const childCmsElements = getChildCmsElements(el)
174
+ const savedEdit = savedEdits[cmsId]
175
+
176
+ let currentHTML = originalHTML
177
+ let newText = originalText
178
+ let isDirty = false
179
+
180
+ if (savedEdit) {
181
+ // Use currentHTML for visual display, newText for the placeholder representation
182
+ currentHTML = savedEdit.currentHTML
183
+ newText = savedEdit.newText
184
+ isDirty = true
185
+ el.innerHTML = currentHTML
186
+ }
187
+
188
+ // Check for styled content after restoring HTML
189
+ const hasStyled = hasStyledContent(el)
190
+
191
+ signals.setPendingChange(cmsId, {
192
+ element: el,
193
+ originalHTML,
194
+ originalText,
195
+ newText,
196
+ currentHTML,
197
+ isDirty,
198
+ childCmsElements,
199
+ hasStyledContent: hasStyled,
200
+ })
201
+ }
202
+
203
+ el.addEventListener('input', (e) => {
204
+ const currentId = signals.currentEditingId.value
205
+ logDebug(
206
+ config.debug,
207
+ 'Input event on',
208
+ cmsId,
209
+ 'currentEditingId:',
210
+ currentId,
211
+ 'target:',
212
+ (e.target as HTMLElement).getAttribute('data-cms-id'),
213
+ )
214
+ if (currentId === cmsId) {
215
+ e.stopPropagation()
216
+ logDebug(config.debug, 'Handling input for', cmsId)
217
+ handleElementChange(config, cmsId, el, onStateChange)
218
+ } else {
219
+ logDebug(config.debug, 'Skipping input - not current editing element, expected:', currentId)
220
+ }
221
+ })
222
+
223
+ el.addEventListener(
224
+ 'click',
225
+ (e) => {
226
+ if (e.detail !== 1) return
227
+
228
+ const innermostCms = findInnermostCmsElement(e.target)
229
+
230
+ if (innermostCms) {
231
+ const targetId = innermostCms.getAttribute('data-cms-id')
232
+ innermostCms.focus()
233
+ signals.setCurrentEditingId(targetId)
234
+ // Update chat context if chat is open
235
+ if (signals.isChatOpen.value && targetId) {
236
+ signals.setChatContextElement(targetId)
237
+ }
238
+ logDebug(config.debug, 'Click - focusing innermost CMS element:', targetId)
239
+ onStateChange?.()
240
+ }
241
+ },
242
+ true,
243
+ )
244
+
245
+ el.addEventListener(
246
+ 'focus',
247
+ (e) => {
248
+ if (e.target === el) {
249
+ signals.setCurrentEditingId(cmsId)
250
+ // Update chat context if chat is open
251
+ if (signals.isChatOpen.value && cmsId) {
252
+ signals.setChatContextElement(cmsId)
253
+ }
254
+ logDebug(config.debug, 'Focus on', cmsId)
255
+ onStateChange?.()
256
+ }
257
+ },
258
+ false,
259
+ )
260
+
261
+ el.addEventListener('blur', (e) => {
262
+ // Don't clear currentEditingId if clicking on CMS UI elements
263
+ const relatedTarget = (e as FocusEvent).relatedTarget as HTMLElement | null
264
+
265
+ // Check if we're focusing on another CMS element
266
+ if (relatedTarget?.hasAttribute(CSS.ID_ATTRIBUTE)) {
267
+ return // Let the new element's focus handler set the ID
268
+ }
269
+
270
+ // Check if we're clicking on CMS UI (toolbar, tooltip, chat) using data-cms-ui attribute
271
+ if (relatedTarget?.hasAttribute(CSS.UI_ATTRIBUTE) || relatedTarget?.closest(`[${CSS.UI_ATTRIBUTE}]`)) {
272
+ return // Keep current selection
273
+ }
274
+
275
+ // Allow a small delay to check if we're clicking inside CMS UI
276
+ setTimeout(() => {
277
+ const activeElement = document.activeElement as HTMLElement | null
278
+
279
+ // Check if active element is inside CMS UI
280
+ if (activeElement?.hasAttribute(CSS.UI_ATTRIBUTE) || activeElement?.closest(`[${CSS.UI_ATTRIBUTE}]`)) {
281
+ return // Keep current selection
282
+ }
283
+
284
+ // Only clear if we actually lost focus to something else
285
+ if (signals.currentEditingId.value === cmsId) {
286
+ signals.setCurrentEditingId(null)
287
+ onStateChange?.()
288
+ }
289
+ }, TIMING.BLUR_DELAY_MS)
290
+ })
291
+ })
292
+
293
+ // Check for pending entry navigation (from collections browser cross-page navigation)
294
+ const pendingEntry = loadPendingEntryNavigation()
295
+ if (pendingEntry) {
296
+ const collectionDef = signals.manifest.value.collectionDefinitions?.[pendingEntry.collectionName]
297
+ if (collectionDef) {
298
+ signals.openMarkdownEditorForEntry(
299
+ pendingEntry.collectionName,
300
+ pendingEntry.slug,
301
+ pendingEntry.sourcePath,
302
+ collectionDef,
303
+ )
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Stop edit mode - disables inline editing.
310
+ */
311
+ export function stopEditMode(onStateChange?: () => void): void {
312
+ signals.setEditing(false)
313
+ signals.setShowingOriginal(false)
314
+ enableAllInteractiveElements()
315
+ cleanupHighlightSystem()
316
+ onStateChange?.()
317
+
318
+ // Close all open dialogs
319
+ signals.closeAttributeEditor()
320
+ signals.closeSeoEditor()
321
+ signals.closeColorEditor()
322
+ signals.resetMediaLibraryState()
323
+ signals.resetMarkdownEditorState()
324
+ signals.resetCreatePageState()
325
+
326
+ getAllCmsElements().forEach(el => {
327
+ makeElementNonEditable(el)
328
+ })
329
+ }
330
+
331
+ /**
332
+ * Handle element content change - tracks dirty state.
333
+ */
334
+ export function handleElementChange(
335
+ config: CmsConfig,
336
+ cmsId: string,
337
+ el: HTMLElement,
338
+ onStateChange?: () => void,
339
+ ): void {
340
+ logDebug(config.debug, 'handleElementChange called for', cmsId)
341
+ const change = signals.getPendingChange(cmsId)
342
+
343
+ if (!change) {
344
+ logDebug(config.debug, 'ERROR: No change tracked for', cmsId)
345
+ logDebug(config.debug, 'Available IDs in pendingChanges:', Array.from(signals.pendingChanges.value.keys()))
346
+ logDebug(config.debug, 'Element:', el.tagName, el.textContent?.substring(0, 50))
347
+ return
348
+ }
349
+
350
+ const newHTML = el.innerHTML
351
+ const hasStyled = hasStyledContent(el)
352
+
353
+ // For styled content, use innerHTML as newText to preserve the styled spans
354
+ // For plain text, use the extracted text
355
+ const newText = hasStyled ? getEditableHtmlFromElement(el) : getEditableTextFromElement(el)
356
+
357
+ const textChanged = newText !== change.originalText
358
+ // Also consider as changed if HTML differs (e.g., styling like bold was applied)
359
+ const htmlChanged = newHTML !== change.originalHTML
360
+ const isDirty = textChanged || htmlChanged
361
+
362
+ const updatedChildElements = change.childCmsElements?.map(child => {
363
+ const childEl = el.querySelector(`[data-cms-id="${child.id}"]`)
364
+ if (childEl) {
365
+ return { ...child, currentHTML: childEl.outerHTML }
366
+ }
367
+ return child
368
+ })
369
+
370
+ // Record undo action before updating signal
371
+ if (!isApplyingUndoRedo) {
372
+ recordTextChange({
373
+ type: 'text',
374
+ cmsId,
375
+ element: el,
376
+ previousHTML: change.currentHTML,
377
+ previousText: change.newText,
378
+ currentHTML: newHTML,
379
+ currentText: newText,
380
+ wasDirty: change.isDirty,
381
+ })
382
+ }
383
+
384
+ // Update the change in signals
385
+ signals.updatePendingChange(cmsId, (c) => ({
386
+ ...c,
387
+ newText,
388
+ currentHTML: newHTML,
389
+ isDirty,
390
+ childCmsElements: updatedChildElements,
391
+ hasStyledContent: hasStyled,
392
+ }))
393
+
394
+ logDebug(config.debug, `Change tracked for ${cmsId}:`, {
395
+ originalText: change.originalText,
396
+ newText,
397
+ isDirty,
398
+ textChanged,
399
+ htmlChanged,
400
+ hasStyledContent: hasStyled,
401
+ })
402
+
403
+ saveEditsToStorage(signals.pendingChanges.value)
404
+ onStateChange?.()
405
+ }
406
+
407
+ /**
408
+ * Toggle showing original content vs edited content.
409
+ */
410
+ export function toggleShowOriginal(
411
+ config: CmsConfig,
412
+ onStateChange?: () => void,
413
+ ): void {
414
+ const newShowingOriginal = !signals.showingOriginal.value
415
+ signals.setShowingOriginal(newShowingOriginal)
416
+
417
+ signals.pendingChanges.value.forEach((change) => {
418
+ if (newShowingOriginal) {
419
+ change.element.innerHTML = change.originalHTML
420
+ makeElementNonEditable(change.element)
421
+ } else {
422
+ change.element.innerHTML = change.currentHTML || change.originalHTML
423
+ makeElementEditable(change.element)
424
+ }
425
+ })
426
+
427
+ // Toggle image sources between original and new
428
+ signals.pendingImageChanges.value.forEach((change) => {
429
+ if (newShowingOriginal) {
430
+ change.element.src = change.originalSrc
431
+ change.element.alt = change.originalAlt
432
+ // Restore original srcset when showing original
433
+ if (change.originalSrcSet) {
434
+ change.element.setAttribute('srcset', change.originalSrcSet)
435
+ }
436
+ } else {
437
+ change.element.src = change.newSrc
438
+ change.element.alt = change.newAlt
439
+ // Clear srcset when showing new image so browser uses src
440
+ if (change.isDirty) {
441
+ change.element.removeAttribute('srcset')
442
+ }
443
+ }
444
+ })
445
+
446
+ onStateChange?.()
447
+ }
448
+
449
+ /**
450
+ * Discard all pending changes and restore original content.
451
+ * Note: Confirmation is handled by the caller (e.g., toolbar).
452
+ */
453
+ export function discardAllChanges(onStateChange?: () => void): void {
454
+ signals.pendingChanges.value.forEach((change) => {
455
+ change.element.innerHTML = change.originalHTML
456
+ makeElementNonEditable(change.element)
457
+ })
458
+
459
+ // Restore original image sources
460
+ signals.pendingImageChanges.value.forEach((change) => {
461
+ change.element.src = change.originalSrc
462
+ change.element.alt = change.originalAlt
463
+ // Restore original srcset
464
+ if (change.originalSrcSet) {
465
+ change.element.setAttribute('srcset', change.originalSrcSet)
466
+ }
467
+ })
468
+
469
+ // Restore original color classes
470
+ signals.pendingColorChanges.value.forEach((change) => {
471
+ const { element, originalClasses, newClasses } = change
472
+ // Remove new color classes and add back original ones
473
+ const classes = element.className.split(/\s+/).filter(Boolean)
474
+ const newClassValues = new Set(Object.values(newClasses).map(a => a.value).filter(Boolean))
475
+ const originalClassValues = new Set(Object.values(originalClasses).map(a => a.value).filter(Boolean))
476
+
477
+ // Filter out new classes
478
+ const filtered = classes.filter(c => !newClassValues.has(c))
479
+ // Add back original classes
480
+ originalClassValues.forEach(c => {
481
+ if (!filtered.includes(c)) {
482
+ filtered.push(c)
483
+ }
484
+ })
485
+ element.className = filtered.join(' ')
486
+
487
+ // Clear inline color styles
488
+ element.style.backgroundColor = ''
489
+ element.style.color = ''
490
+ })
491
+
492
+ // Restore original attributes
493
+ signals.pendingAttributeChanges.value.forEach((change) => {
494
+ const { element, originalAttributes } = change
495
+ applyAttributesToElement(element, originalAttributes)
496
+ })
497
+
498
+ cleanupHighlightSystem()
499
+ signals.clearPendingChanges()
500
+ signals.clearPendingImageChanges()
501
+ signals.clearPendingColorChanges()
502
+ signals.clearPendingAttributeChanges()
503
+ clearAllEditsFromStorage()
504
+ clearHistory()
505
+ stopEditMode(onStateChange)
506
+ }
507
+
508
+ function findSeoSourceById(
509
+ seoData: import('./types').PageSeoData | undefined,
510
+ id: string,
511
+ ): { sourcePath: string; sourceLine: number; sourceSnippet: string; content: string } | null {
512
+ if (!seoData) return null
513
+
514
+ const fields = [
515
+ seoData.title,
516
+ seoData.description,
517
+ seoData.keywords,
518
+ seoData.canonical,
519
+ ...(seoData.openGraph ? Object.values(seoData.openGraph) : []),
520
+ ...(seoData.twitterCard ? Object.values(seoData.twitterCard) : []),
521
+ ]
522
+
523
+ for (const field of fields) {
524
+ if (field && (field as any).id === id) {
525
+ return {
526
+ sourcePath: field.sourcePath ?? '',
527
+ sourceLine: field.sourceLine ?? 0,
528
+ sourceSnippet: field.sourceSnippet ?? '',
529
+ content: (field as any).content ?? (field as any).href ?? '',
530
+ }
531
+ }
532
+ }
533
+ return null
534
+ }
535
+
536
+ /**
537
+ * Save all dirty changes to the server.
538
+ */
539
+ export async function saveAllChanges(
540
+ config: CmsConfig,
541
+ onStateChange?: () => void,
542
+ ): Promise<{ success: boolean; updated: number; errors?: Array<{ cmsId: string; error: string }> }> {
543
+ const dirtyChanges = signals.dirtyChanges.value
544
+ const dirtyImageChanges = signals.dirtyImageChanges.value
545
+ const dirtyColorChanges = signals.dirtyColorChanges.value
546
+ const dirtyAttributeChanges = signals.dirtyAttributeChanges.value
547
+ const dirtySeoChanges = signals.dirtySeoChanges.value
548
+
549
+ if (
550
+ dirtyChanges.length === 0 && dirtyImageChanges.length === 0 && dirtyColorChanges.length === 0 && dirtyAttributeChanges.length === 0
551
+ && dirtySeoChanges.length === 0
552
+ ) {
553
+ return { success: true, updated: 0 }
554
+ }
555
+
556
+ signals.isSaving.value = true
557
+ try {
558
+ const manifest = signals.manifest.value
559
+ console.log('[CMS] Manifest entries keys:', Object.keys(manifest.entries).slice(0, 10))
560
+
561
+ const changes: ChangePayload[] = dirtyChanges.map(([cmsId, change]) => {
562
+ const entry = manifest.entries[cmsId]
563
+
564
+ // Debug: log entry lookup
565
+ if (!entry) {
566
+ console.warn(`[CMS] No manifest entry found for ${cmsId}. Available keys:`, Object.keys(manifest.entries))
567
+ } else if (!entry.sourcePath) {
568
+ console.warn(`[CMS] Entry ${cmsId} has no sourcePath:`, JSON.stringify(entry, null, 2))
569
+ }
570
+
571
+ const payload: ChangePayload = {
572
+ cmsId,
573
+ newValue: change.newText,
574
+ originalValue: entry?.text ?? change.originalText,
575
+ sourcePath: entry?.sourcePath ?? '',
576
+ sourceLine: entry?.sourceLine ?? 0,
577
+ sourceSnippet: entry?.sourceSnippet ?? '',
578
+ }
579
+
580
+ if (change.childCmsElements && change.childCmsElements.length > 0) {
581
+ payload.childCmsIds = change.childCmsElements.map(c => c.id)
582
+ }
583
+
584
+ // Include HTML content when there are styled spans
585
+ if (change.hasStyledContent) {
586
+ payload.hasStyledContent = true
587
+ payload.htmlValue = getEditableHtmlFromElement(change.element)
588
+ }
589
+
590
+ return payload
591
+ })
592
+
593
+ // Add image changes to the payload
594
+ dirtyImageChanges.forEach(([cmsId, change]) => {
595
+ const entry = manifest.entries[cmsId]
596
+ changes.push({
597
+ cmsId,
598
+ newValue: change.newSrc,
599
+ originalValue: change.originalSrc,
600
+ sourcePath: entry?.sourcePath ?? '',
601
+ sourceLine: entry?.sourceLine ?? 0,
602
+ sourceSnippet: entry?.sourceSnippet ?? '',
603
+ imageChange: {
604
+ newSrc: change.newSrc,
605
+ newAlt: change.newAlt,
606
+ },
607
+ })
608
+ })
609
+
610
+ // Add color changes to the payload
611
+ dirtyColorChanges.forEach(([cmsId, change]) => {
612
+ // For each color type that changed, add a separate change entry
613
+ const { originalClasses, newClasses } = change
614
+ const entry = manifest.entries[cmsId]
615
+ const colorTypes = ['bg', 'text', 'border', 'hoverBg', 'hoverText'] as const
616
+
617
+ // Find the best source info from any color type that has it
618
+ // (all color types share the same class attribute on the same element)
619
+ let sharedSourcePath: string | undefined
620
+ let sharedSourceLine: number | undefined
621
+ let sharedSourceSnippet: string | undefined
622
+ for (const ct of colorTypes) {
623
+ const orig = originalClasses[ct]
624
+ const curr = newClasses[ct]
625
+ const sp = curr?.sourcePath ?? orig?.sourcePath
626
+ const sl = curr?.sourceLine ?? orig?.sourceLine
627
+ if (sp && sl) {
628
+ sharedSourcePath = sp
629
+ sharedSourceLine = sl
630
+ sharedSourceSnippet = curr?.sourceSnippet ?? orig?.sourceSnippet
631
+ break
632
+ }
633
+ }
634
+
635
+ for (const colorType of colorTypes) {
636
+ const origAttr = originalClasses[colorType]
637
+ const newAttr = newClasses[colorType]
638
+ if (newAttr?.value && newAttr.value !== origAttr?.value) {
639
+ const bestSourcePath = newAttr.sourcePath ?? origAttr?.sourcePath ?? sharedSourcePath
640
+ const bestSourceLine = newAttr.sourceLine ?? origAttr?.sourceLine ?? sharedSourceLine
641
+ const bestSourceSnippet = newAttr.sourceSnippet ?? origAttr?.sourceSnippet ?? sharedSourceSnippet
642
+ changes.push({
643
+ cmsId,
644
+ newValue: '',
645
+ originalValue: '',
646
+ sourcePath: bestSourcePath ?? entry?.sourcePath ?? '',
647
+ sourceLine: bestSourceLine ?? entry?.sourceLine ?? 0,
648
+ sourceSnippet: bestSourceSnippet ?? entry?.sourceSnippet ?? '',
649
+ colorChange: {
650
+ oldClass: origAttr?.value || '',
651
+ newClass: newAttr.value,
652
+ type: colorType,
653
+ sourcePath: bestSourcePath,
654
+ sourceLine: bestSourceLine,
655
+ sourceSnippet: bestSourceSnippet,
656
+ },
657
+ })
658
+ }
659
+ }
660
+ })
661
+
662
+ // Add attribute changes to the payload
663
+ dirtyAttributeChanges.forEach(([cmsId, change]) => {
664
+ const { originalAttributes, newAttributes } = change
665
+ const entry = manifest.entries[cmsId]
666
+ const attributeChanges = buildAttributeChangePayload(originalAttributes, newAttributes)
667
+
668
+ if (attributeChanges.length > 0) {
669
+ // Use source info from first changed attribute, or fall back to element-level
670
+ const firstChange = attributeChanges[0]!
671
+ changes.push({
672
+ cmsId,
673
+ newValue: '',
674
+ originalValue: '',
675
+ sourcePath: firstChange.sourcePath ?? entry?.sourcePath ?? '',
676
+ sourceLine: firstChange.sourceLine ?? entry?.sourceLine ?? 0,
677
+ sourceSnippet: firstChange.sourceSnippet ?? entry?.sourceSnippet ?? '',
678
+ attributeChanges,
679
+ })
680
+ }
681
+ })
682
+
683
+ // Add SEO changes to the payload
684
+ dirtySeoChanges.forEach(([cmsId, change]) => {
685
+ const seoData = (manifest as any).seo as import('./types').PageSeoData | undefined
686
+ const sourceInfo = findSeoSourceById(seoData, cmsId)
687
+ changes.push({
688
+ cmsId,
689
+ newValue: change.newValue,
690
+ originalValue: sourceInfo?.content ?? change.originalValue,
691
+ sourcePath: sourceInfo?.sourcePath ?? '',
692
+ sourceLine: sourceInfo?.sourceLine ?? 0,
693
+ sourceSnippet: sourceInfo?.sourceSnippet ?? '',
694
+ })
695
+ })
696
+
697
+ const result = await saveBatchChanges(config.apiBase, {
698
+ changes,
699
+ meta: {
700
+ source: 'inline-editor',
701
+ url: window.location.href,
702
+ },
703
+ })
704
+
705
+ // Update all dirty text changes to mark as saved
706
+ signals.batch(() => {
707
+ dirtyChanges.forEach(([cmsId, change]) => {
708
+ signals.updatePendingChange(cmsId, (c) => ({
709
+ ...c,
710
+ originalText: c.newText,
711
+ originalHTML: c.element.innerHTML,
712
+ currentHTML: c.element.innerHTML,
713
+ isDirty: false,
714
+ }))
715
+ })
716
+
717
+ // Update all dirty image changes to mark as saved
718
+ dirtyImageChanges.forEach(([cmsId, change]) => {
719
+ signals.updatePendingImageChange(cmsId, (c) => ({
720
+ ...c,
721
+ originalSrc: c.newSrc,
722
+ originalAlt: c.newAlt,
723
+ isDirty: false,
724
+ }))
725
+ })
726
+
727
+ // Update all dirty color changes to mark as saved
728
+ dirtyColorChanges.forEach(([cmsId, change]) => {
729
+ signals.updatePendingColorChange(cmsId, (c) => ({
730
+ ...c,
731
+ originalClasses: { ...c.newClasses },
732
+ isDirty: false,
733
+ }))
734
+ })
735
+
736
+ // Update all dirty attribute changes to mark as saved
737
+ dirtyAttributeChanges.forEach(([cmsId, change]) => {
738
+ signals.updatePendingAttributeChange(cmsId, (c) => ({
739
+ ...c,
740
+ originalAttributes: { ...c.newAttributes },
741
+ isDirty: false,
742
+ }))
743
+ })
744
+
745
+ // Update all dirty SEO changes to mark as saved
746
+ dirtySeoChanges.forEach(([cmsId, change]) => {
747
+ signals.updatePendingSeoChange(cmsId, (c) => ({
748
+ ...c,
749
+ originalValue: c.newValue,
750
+ isDirty: false,
751
+ }))
752
+ })
753
+ })
754
+
755
+ clearAllEditsFromStorage()
756
+ clearHistory()
757
+
758
+ // Close all open dialogs after save
759
+ signals.closeAttributeEditor()
760
+ signals.closeSeoEditor()
761
+ signals.closeColorEditor()
762
+ signals.resetMediaLibraryState()
763
+ signals.resetMarkdownEditorState()
764
+ signals.resetCreatePageState()
765
+
766
+ if (result.errors && result.errors.length > 0) {
767
+ console.error('[CMS] Save errors:', result.errors)
768
+ return { success: false, updated: result.updated, errors: result.errors }
769
+ }
770
+
771
+ // Start polling for deployment status after successful save
772
+ startDeploymentPolling(config)
773
+
774
+ onStateChange?.()
775
+ return { success: true, updated: result.updated }
776
+ } catch (err) {
777
+ console.error('[CMS] Save failed:', err)
778
+ // Save all edits to storage on failure so they can be recovered
779
+ saveEditsToStorage(signals.pendingChanges.value)
780
+ saveImageEditsToStorage(signals.pendingImageChanges.value)
781
+ saveColorEditsToStorage(signals.pendingColorChanges.value)
782
+ saveAttributeEditsToStorage(signals.pendingAttributeChanges.value)
783
+ throw err
784
+ } finally {
785
+ signals.isSaving.value = false
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Setup click handler for markdown elements.
791
+ * When a markdown element is clicked, it opens the WYSIWYG editor instead of using contentEditable.
792
+ */
793
+ function setupMarkdownClickHandler(
794
+ config: CmsConfig,
795
+ el: HTMLElement,
796
+ cmsId: string,
797
+ _onStateChange?: () => void,
798
+ ): void {
799
+ // Add visual indicator that this is a markdown-editable element
800
+ el.style.cursor = 'pointer'
801
+
802
+ el.addEventListener('click', async (e) => {
803
+ e.preventDefault()
804
+ e.stopPropagation()
805
+
806
+ logDebug(config.debug, 'Markdown element clicked:', cmsId)
807
+
808
+ // Refresh manifest to get the latest content
809
+ try {
810
+ const newManifest = await fetchManifest()
811
+ signals.setManifest(newManifest)
812
+ } catch (err) {
813
+ console.error('[CMS] Failed to refresh manifest:', err)
814
+ // Continue with current manifest if refresh fails
815
+ }
816
+
817
+ // Get the manifest entry to find the markdown file path
818
+ const manifest = signals.manifest.value
819
+ const entry = manifest.entries[cmsId] as ManifestEntry | undefined
820
+
821
+ if (!entry) {
822
+ signals.showToast('Markdown element not found in manifest', 'error')
823
+ return
824
+ }
825
+
826
+ // Check if it has a content path for markdown
827
+ const contentPath = entry.contentPath
828
+ if (!contentPath) {
829
+ signals.showToast('No markdown file path configured for this element', 'error')
830
+ return
831
+ }
832
+
833
+ // Fetch the markdown content from the API
834
+ try {
835
+ const result = await getMarkdownContent(config.apiBase, contentPath)
836
+
837
+ if (!result) {
838
+ signals.showToast('Markdown content not found', 'error')
839
+ return
840
+ }
841
+
842
+ // Set the markdown page data
843
+ signals.setMarkdownPage({
844
+ filePath: result.filePath,
845
+ slug: entry.collectionSlug || '',
846
+ frontmatter: result.frontmatter as import('./types').BlogFrontmatter,
847
+ content: result.content,
848
+ isDirty: false,
849
+ })
850
+
851
+ // Set the markdown editor to open with this element
852
+ signals.setMarkdownActiveElement(cmsId)
853
+ signals.setMarkdownEditorOpen(true)
854
+ } catch (error) {
855
+ const message = error instanceof Error ? error.message : 'Unknown error'
856
+ signals.showToast(`Failed to load markdown: ${message}`, 'error')
857
+ logDebug(config.debug, 'Failed to fetch markdown content:', error)
858
+ }
859
+ })
860
+ }
861
+
862
+ /**
863
+ * Setup click handler for image elements.
864
+ * When an image is clicked, it opens the media library to select a replacement.
865
+ */
866
+ function setupImageClickHandler(
867
+ config: CmsConfig,
868
+ el: HTMLImageElement,
869
+ cmsId: string,
870
+ savedEdit: import('./types').SavedImageEdit | undefined,
871
+ onStateChange?: () => void,
872
+ ): void {
873
+ // Add visual indicator that this is a replaceable image
874
+ el.style.cursor = 'pointer'
875
+
876
+ // Store original values for change tracking
877
+ // Use getAttribute to get the original attribute value (e.g., "/assets/image.webp")
878
+ // instead of .src which returns the fully resolved URL (e.g., "http://localhost/assets/image.webp")
879
+ const originalSrc = el.getAttribute('src') || el.src
880
+ const originalAlt = el.alt || ''
881
+ const originalSrcSet = el.getAttribute('srcset') || ''
882
+
883
+ // Initialize pending image change if not already tracked
884
+ if (!signals.pendingImageChanges.value.has(cmsId)) {
885
+ // Restore saved edit if present
886
+ if (savedEdit) {
887
+ el.src = savedEdit.newSrc
888
+ el.alt = savedEdit.newAlt
889
+ // Clear srcset so browser uses the new src
890
+ el.removeAttribute('srcset')
891
+ signals.setPendingImageChange(cmsId, {
892
+ element: el,
893
+ originalSrc: savedEdit.originalSrc,
894
+ newSrc: savedEdit.newSrc,
895
+ originalAlt: savedEdit.originalAlt,
896
+ newAlt: savedEdit.newAlt,
897
+ originalSrcSet: savedEdit.originalSrcSet ?? originalSrcSet,
898
+ isDirty: true,
899
+ })
900
+ logDebug(config.debug, 'Restored saved image edit:', cmsId, savedEdit)
901
+ } else {
902
+ signals.setPendingImageChange(cmsId, {
903
+ element: el,
904
+ originalSrc,
905
+ newSrc: originalSrc,
906
+ originalAlt,
907
+ newAlt: originalAlt,
908
+ originalSrcSet,
909
+ isDirty: false,
910
+ })
911
+ }
912
+ }
913
+
914
+ el.addEventListener('click', (e) => {
915
+ e.preventDefault()
916
+ e.stopPropagation()
917
+
918
+ logDebug(config.debug, 'Image element clicked:', cmsId)
919
+
920
+ // Open media library with callback to handle image replacement
921
+ signals.openMediaLibraryWithCallback((url: string, alt: string) => {
922
+ logDebug(config.debug, 'Image replacement selected:', { cmsId, url, alt })
923
+
924
+ // Record undo action before mutation
925
+ const currentChange = signals.getPendingImageChange(cmsId)
926
+ if (!isApplyingUndoRedo && currentChange) {
927
+ recordChange({
928
+ type: 'image',
929
+ cmsId,
930
+ element: el,
931
+ previousSrc: currentChange.newSrc,
932
+ previousAlt: currentChange.newAlt,
933
+ currentSrc: url,
934
+ currentAlt: alt || currentChange.originalAlt,
935
+ wasDirty: currentChange.isDirty,
936
+ })
937
+ }
938
+
939
+ // Update the image element
940
+ el.src = url
941
+ // Clear srcset so browser uses the new src
942
+ el.removeAttribute('srcset')
943
+ if (alt) {
944
+ el.alt = alt
945
+ }
946
+
947
+ // Track the change
948
+ const isDirty = url !== (currentChange?.originalSrc ?? originalSrc)
949
+
950
+ signals.updatePendingImageChange(cmsId, (change) => ({
951
+ ...change,
952
+ newSrc: url,
953
+ newAlt: alt || change.originalAlt,
954
+ isDirty,
955
+ }))
956
+
957
+ saveImageEditsToStorage(signals.pendingImageChanges.value)
958
+ onStateChange?.()
959
+ })
960
+ })
961
+ }
962
+
963
+ /**
964
+ * Initialize color change tracking for elements with color classes.
965
+ * Color editing is now triggered via the outline component's color swatches.
966
+ */
967
+ function setupColorTracking(
968
+ config: CmsConfig,
969
+ el: HTMLElement,
970
+ cmsId: string,
971
+ savedEdit: import('./types').SavedColorEdit | undefined,
972
+ ): void {
973
+ // Get the manifest entry to find the color classes
974
+ const manifest = signals.manifest.value
975
+ const entry = manifest.entries[cmsId]
976
+
977
+ if (!entry?.colorClasses) {
978
+ return
979
+ }
980
+
981
+ logDebug(config.debug, 'Setting up color tracking for:', cmsId, entry.colorClasses)
982
+
983
+ // Initialize pending color change if not already tracked
984
+ if (!signals.pendingColorChanges.value.has(cmsId)) {
985
+ // Restore saved edit if present
986
+ if (savedEdit) {
987
+ // Apply saved color classes to the element
988
+ const classes = el.className.split(/\s+/).filter(Boolean)
989
+ const originalClassValues = new Set(Object.values(savedEdit.originalClasses).map(a => a.value).filter(Boolean))
990
+ const newClassValues = new Set(Object.values(savedEdit.newClasses).map(a => a.value).filter(Boolean))
991
+
992
+ // Remove original classes and add new ones
993
+ const filtered = classes.filter(c => !originalClassValues.has(c))
994
+ newClassValues.forEach(c => {
995
+ if (!filtered.includes(c)) {
996
+ filtered.push(c)
997
+ }
998
+ })
999
+ el.className = filtered.join(' ')
1000
+
1001
+ signals.setPendingColorChange(cmsId, {
1002
+ element: el,
1003
+ cmsId,
1004
+ originalClasses: savedEdit.originalClasses,
1005
+ newClasses: savedEdit.newClasses,
1006
+ isDirty: true,
1007
+ })
1008
+ logDebug(config.debug, 'Restored saved color edit:', cmsId, savedEdit)
1009
+ } else {
1010
+ const originalClasses = deepCopyColorClasses(entry.colorClasses)
1011
+ signals.setPendingColorChange(cmsId, {
1012
+ element: el,
1013
+ cmsId,
1014
+ originalClasses,
1015
+ newClasses: deepCopyColorClasses(entry.colorClasses),
1016
+ isDirty: false,
1017
+ })
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * Handle color change from the color toolbar.
1024
+ * Called when user selects a new color.
1025
+ */
1026
+ export function handleColorChange(
1027
+ config: CmsConfig,
1028
+ cmsId: string,
1029
+ colorType: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText',
1030
+ oldClass: string,
1031
+ newClass: string,
1032
+ onStateChange?: () => void,
1033
+ previousClassName?: string,
1034
+ previousStyleCssText?: string,
1035
+ ): void {
1036
+ const change = signals.getPendingColorChange(cmsId)
1037
+ if (!change) {
1038
+ logDebug(config.debug, 'No color change tracked for', cmsId)
1039
+ return
1040
+ }
1041
+
1042
+ // Record undo action (DOM is already mutated by applyColorChange in color-toolbar)
1043
+ if (!isApplyingUndoRedo && previousClassName !== undefined) {
1044
+ const prevClasses: Record<string, Attribute> = {}
1045
+ for (const [key, attr] of Object.entries(change.newClasses)) {
1046
+ prevClasses[key] = { ...attr }
1047
+ }
1048
+
1049
+ // Compute what the new classes will be after this change
1050
+ const nextClasses: Record<string, Attribute> = { ...change.newClasses }
1051
+ const existingAttrForNext = nextClasses[colorType] || change.originalClasses[colorType]
1052
+ nextClasses[colorType] = { ...(existingAttrForNext || {}), value: newClass }
1053
+
1054
+ recordChange({
1055
+ type: 'color',
1056
+ cmsId,
1057
+ element: change.element,
1058
+ previousClassName,
1059
+ currentClassName: change.element.className, // Already mutated by applyColorChange
1060
+ previousStyleCssText: previousStyleCssText ?? '',
1061
+ currentStyleCssText: change.element.style.cssText, // Already mutated by applyColorChange
1062
+ previousClasses: prevClasses,
1063
+ currentClasses: nextClasses,
1064
+ wasDirty: change.isDirty,
1065
+ })
1066
+ }
1067
+
1068
+ // Update the new classes - preserve source info from original, update value
1069
+ const newClasses = { ...change.newClasses }
1070
+ const existingAttr = newClasses[colorType] || change.originalClasses[colorType]
1071
+ newClasses[colorType] = {
1072
+ ...(existingAttr || {}),
1073
+ value: newClass,
1074
+ }
1075
+
1076
+ // Check if dirty (any class value different from original)
1077
+ let isDirty = false
1078
+ const allKeys = new Set([...Object.keys(change.originalClasses), ...Object.keys(newClasses)])
1079
+ for (const key of allKeys) {
1080
+ if (change.originalClasses[key]?.value !== newClasses[key]?.value) {
1081
+ isDirty = true
1082
+ break
1083
+ }
1084
+ }
1085
+
1086
+ signals.updatePendingColorChange(cmsId, (c) => ({
1087
+ ...c,
1088
+ newClasses,
1089
+ isDirty,
1090
+ }))
1091
+
1092
+ logDebug(config.debug, 'Color change recorded:', { cmsId, colorType, oldClass, newClass, isDirty })
1093
+
1094
+ saveColorEditsToStorage(signals.pendingColorChanges.value)
1095
+ onStateChange?.()
1096
+ }
1097
+
1098
+ // ============================================================================
1099
+ // Deployment Status Polling
1100
+ // ============================================================================
1101
+
1102
+ const DEPLOYMENT_POLL_INTERVAL_MS = 3000
1103
+ const DEPLOYMENT_SUCCESS_HIDE_DELAY_MS = 5000
1104
+ const DEPLOYMENT_INITIAL_DELAY_MS = 2000
1105
+ const DEPLOYMENT_MAX_WAIT_ATTEMPTS = 10 // Keep polling for up to 30 seconds waiting for deployment to start
1106
+
1107
+ let deploymentPollTimer: ReturnType<typeof setInterval> | null = null
1108
+ let deploymentHideTimer: ReturnType<typeof setTimeout> | null = null
1109
+ let deploymentWaitAttempts = 0
1110
+ let deploymentStartTimestamp: string | null = null
1111
+ let deploymentCallback: ((status: 'completed' | 'failed' | 'timeout') => void) | null = null
1112
+
1113
+ export interface DeploymentPollingOptions {
1114
+ /** Called when deployment completes, fails, or times out */
1115
+ onComplete?: (status: 'completed' | 'failed' | 'timeout') => void
1116
+ }
1117
+
1118
+ /**
1119
+ * Start polling for deployment status after a save operation.
1120
+ * Polls the API every 3 seconds until deployment completes or fails.
1121
+ * Waits for deployment to appear for up to 30 seconds before giving up.
1122
+ */
1123
+ export function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): void {
1124
+ // Clear any existing timers
1125
+ stopDeploymentPolling()
1126
+
1127
+ // Reset wait attempts counter and store the timestamp when we started
1128
+ deploymentWaitAttempts = 0
1129
+ deploymentStartTimestamp = new Date().toISOString()
1130
+ deploymentCallback = options?.onComplete ?? null
1131
+
1132
+ // Set initial status to indicate deployment started
1133
+ signals.updateDeploymentState({
1134
+ status: 'pending',
1135
+ isPolling: true,
1136
+ error: null,
1137
+ })
1138
+
1139
+ const poll = async () => {
1140
+ try {
1141
+ const status: DeploymentStatusResponse = await getDeploymentStatus(config.apiBase)
1142
+
1143
+ if (status.currentDeployment) {
1144
+ // Found an active deployment - reset wait counter
1145
+ deploymentWaitAttempts = 0
1146
+
1147
+ signals.updateDeploymentState({
1148
+ status: status.currentDeployment.status,
1149
+ })
1150
+
1151
+ // Check if deployment is still active
1152
+ const isActive = ['pending', 'queued', 'running'].includes(status.currentDeployment.status)
1153
+
1154
+ if (!isActive) {
1155
+ // Deployment finished
1156
+ const cb = deploymentCallback
1157
+ stopDeploymentPolling()
1158
+
1159
+ if (status.currentDeployment.status === 'completed') {
1160
+ // Update last deployed timestamp
1161
+ if (status.lastSuccessfulDeployment) {
1162
+ signals.setLastDeployedAt(status.lastSuccessfulDeployment.completedAt)
1163
+ }
1164
+
1165
+ // Auto-hide after 5 seconds for successful deployments
1166
+ deploymentHideTimer = setTimeout(() => {
1167
+ signals.resetDeploymentState()
1168
+ }, DEPLOYMENT_SUCCESS_HIDE_DELAY_MS)
1169
+
1170
+ cb?.('completed')
1171
+ } else {
1172
+ // For failed deployments, keep showing until user dismisses
1173
+ cb?.('failed')
1174
+ }
1175
+ }
1176
+ } else {
1177
+ // No active deployment found
1178
+ deploymentWaitAttempts++
1179
+
1180
+ // Check if we have a recent successful deployment (completed after we started polling)
1181
+ if (status.lastSuccessfulDeployment && deploymentStartTimestamp) {
1182
+ const lastDeployTime = new Date(status.lastSuccessfulDeployment.completedAt).getTime()
1183
+ const startTime = new Date(deploymentStartTimestamp).getTime()
1184
+
1185
+ if (lastDeployTime > startTime) {
1186
+ // Deployment completed after we started - show success
1187
+ signals.updateDeploymentState({
1188
+ status: 'completed',
1189
+ lastDeployedAt: status.lastSuccessfulDeployment.completedAt,
1190
+ isPolling: false,
1191
+ })
1192
+
1193
+ // Auto-hide after 5 seconds
1194
+ deploymentHideTimer = setTimeout(() => {
1195
+ signals.resetDeploymentState()
1196
+ }, DEPLOYMENT_SUCCESS_HIDE_DELAY_MS)
1197
+
1198
+ const cb = deploymentCallback
1199
+ stopDeploymentPolling()
1200
+ cb?.('completed')
1201
+ return
1202
+ }
1203
+ }
1204
+
1205
+ // Keep waiting if we haven't exceeded max attempts
1206
+ if (deploymentWaitAttempts >= DEPLOYMENT_MAX_WAIT_ATTEMPTS) {
1207
+ // Give up waiting - deployment may have failed to start
1208
+ console.warn('[CMS] No deployment found after waiting, giving up')
1209
+ const cb = deploymentCallback
1210
+ signals.resetDeploymentState()
1211
+ stopDeploymentPolling()
1212
+ cb?.('timeout')
1213
+ }
1214
+ // Otherwise keep polling with "pending" status
1215
+ }
1216
+ } catch (error) {
1217
+ console.error('[CMS] Failed to fetch deployment status:', error)
1218
+ signals.updateDeploymentState({
1219
+ status: 'failed',
1220
+ error: error instanceof Error ? error.message : 'Unknown error',
1221
+ isPolling: false,
1222
+ })
1223
+ const cb = deploymentCallback
1224
+ stopDeploymentPolling()
1225
+ cb?.('failed')
1226
+ }
1227
+ }
1228
+
1229
+ // Delay initial poll to allow deployment to be registered
1230
+ setTimeout(() => {
1231
+ poll()
1232
+ // Then poll every 3 seconds
1233
+ deploymentPollTimer = setInterval(poll, DEPLOYMENT_POLL_INTERVAL_MS)
1234
+ }, DEPLOYMENT_INITIAL_DELAY_MS)
1235
+ }
1236
+
1237
+ /**
1238
+ * Stop polling for deployment status.
1239
+ */
1240
+ export function stopDeploymentPolling(): void {
1241
+ if (deploymentPollTimer) {
1242
+ clearInterval(deploymentPollTimer)
1243
+ deploymentPollTimer = null
1244
+ }
1245
+ deploymentWaitAttempts = 0
1246
+ deploymentStartTimestamp = null
1247
+ deploymentCallback = null
1248
+ signals.setDeploymentPolling(false)
1249
+ }
1250
+
1251
+ /**
1252
+ * Dismiss the deployment status indicator.
1253
+ * Used when user clicks on a failed deployment status.
1254
+ */
1255
+ export function dismissDeploymentStatus(): void {
1256
+ if (deploymentHideTimer) {
1257
+ clearTimeout(deploymentHideTimer)
1258
+ deploymentHideTimer = null
1259
+ }
1260
+ stopDeploymentPolling()
1261
+ signals.resetDeploymentState()
1262
+ }
1263
+
1264
+ // ============================================================================
1265
+ // Attribute Tracking
1266
+ // ============================================================================
1267
+
1268
+ /**
1269
+ * Initialize attribute change tracking for elements with editable attributes.
1270
+ * Called during edit mode setup.
1271
+ */
1272
+ function setupAttributeTracking(
1273
+ config: CmsConfig,
1274
+ el: HTMLElement,
1275
+ cmsId: string,
1276
+ savedEdit: SavedAttributeEdit | undefined,
1277
+ ): void {
1278
+ // Get the manifest entry to find the attributes
1279
+ const manifest = signals.manifest.value
1280
+ const entry = manifest.entries[cmsId]
1281
+
1282
+ // Check if element has any editable attributes
1283
+ if (!entry?.attributes || Object.keys(entry.attributes).length === 0) {
1284
+ return
1285
+ }
1286
+
1287
+ logDebug(config.debug, 'Setting up attribute tracking for:', cmsId)
1288
+
1289
+ // Initialize pending attribute change if not already tracked
1290
+ if (!signals.pendingAttributeChanges.value.has(cmsId)) {
1291
+ // Build original attributes from manifest entry (flat map)
1292
+ const originalAttributes = deepCopyAttributes(entry.attributes)
1293
+
1294
+ // Restore saved edit if present
1295
+ if (savedEdit) {
1296
+ // Apply saved attribute values to the element
1297
+ applyAttributesToElement(el, savedEdit.newAttributes)
1298
+
1299
+ signals.setPendingAttributeChange(cmsId, {
1300
+ element: el,
1301
+ cmsId,
1302
+ originalAttributes: savedEdit.originalAttributes,
1303
+ newAttributes: savedEdit.newAttributes,
1304
+ isDirty: true,
1305
+ })
1306
+ logDebug(config.debug, 'Restored saved attribute edit:', cmsId, savedEdit)
1307
+ } else {
1308
+ // Create deep copy for newAttributes to avoid shared references
1309
+ const newAttributes = deepCopyAttributes(entry.attributes)
1310
+
1311
+ signals.setPendingAttributeChange(cmsId, {
1312
+ element: el,
1313
+ cmsId,
1314
+ originalAttributes,
1315
+ newAttributes,
1316
+ isDirty: false,
1317
+ })
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ /**
1323
+ * Deep copy flat attribute map to avoid shared object references.
1324
+ */
1325
+ function deepCopyAttributes(attrs: Record<string, Attribute>): Record<string, Attribute> {
1326
+ const copy: Record<string, Attribute> = {}
1327
+ for (const [key, attr] of Object.entries(attrs)) {
1328
+ copy[key] = { ...attr }
1329
+ }
1330
+ return copy
1331
+ }
1332
+
1333
+ /**
1334
+ * Deep copy color classes map to avoid shared references.
1335
+ */
1336
+ function deepCopyColorClasses(classes: Record<string, Attribute>): Record<string, Attribute> {
1337
+ const copy: Record<string, Attribute> = {}
1338
+ for (const [key, attr] of Object.entries(classes)) {
1339
+ copy[key] = { ...attr }
1340
+ }
1341
+ return copy
1342
+ }
1343
+
1344
+ /**
1345
+ * Handle attribute change from the attribute editor.
1346
+ * Called when user modifies an attribute value.
1347
+ */
1348
+ export function handleAttributeChange(
1349
+ config: CmsConfig,
1350
+ cmsId: string,
1351
+ attributeName: string,
1352
+ newValue: string | boolean | number | undefined,
1353
+ onStateChange?: () => void,
1354
+ ): void {
1355
+ const change = signals.getPendingAttributeChange(cmsId)
1356
+ if (!change) {
1357
+ logDebug(config.debug, 'No attribute change tracked for', cmsId)
1358
+ return
1359
+ }
1360
+
1361
+ // Record undo action before mutation
1362
+ if (!isApplyingUndoRedo) {
1363
+ const prevAttrs: Record<string, Attribute> = {}
1364
+ for (const [key, attr] of Object.entries(change.newAttributes)) {
1365
+ prevAttrs[key] = { ...attr }
1366
+ }
1367
+
1368
+ const nextAttrs: Record<string, Attribute> = { ...change.newAttributes }
1369
+ const existingAttrForNext = nextAttrs[attributeName] || change.originalAttributes[attributeName]
1370
+ nextAttrs[attributeName] = {
1371
+ ...(existingAttrForNext || {}),
1372
+ value: newValue === undefined ? '' : String(newValue),
1373
+ }
1374
+
1375
+ recordChange({
1376
+ type: 'attribute',
1377
+ cmsId,
1378
+ element: change.element,
1379
+ previousAttributes: prevAttrs,
1380
+ currentAttributes: nextAttrs,
1381
+ wasDirty: change.isDirty,
1382
+ })
1383
+ }
1384
+
1385
+ // Update the new attributes — preserve source info, update value
1386
+ const newAttributes = { ...change.newAttributes }
1387
+ const existingAttr = newAttributes[attributeName] || change.originalAttributes[attributeName]
1388
+ newAttributes[attributeName] = {
1389
+ ...(existingAttr || {}),
1390
+ value: newValue === undefined ? '' : String(newValue),
1391
+ }
1392
+
1393
+ // Apply the change to the DOM element
1394
+ applyAttributeToElement(change.element, attributeName, newValue)
1395
+
1396
+ // Check if dirty (any attribute different from original)
1397
+ const isDirty = checkAttributesDirty(change.originalAttributes, newAttributes)
1398
+
1399
+ signals.updatePendingAttributeChange(cmsId, (c) => ({
1400
+ ...c,
1401
+ newAttributes,
1402
+ isDirty,
1403
+ }))
1404
+
1405
+ logDebug(config.debug, 'Attribute change recorded:', { cmsId, attributeName, newValue, isDirty })
1406
+
1407
+ saveAttributeEditsToStorage(signals.pendingAttributeChanges.value)
1408
+ onStateChange?.()
1409
+ }
1410
+
1411
+ /** Boolean attribute names that use presence/absence rather than string values */
1412
+ const BOOLEAN_ATTRIBUTES = new Set([
1413
+ 'disabled',
1414
+ 'required',
1415
+ 'readonly',
1416
+ 'multiple',
1417
+ 'controls',
1418
+ 'autoplay',
1419
+ 'muted',
1420
+ 'loop',
1421
+ 'novalidate',
1422
+ 'download',
1423
+ 'aria-hidden',
1424
+ 'aria-expanded',
1425
+ 'aria-disabled',
1426
+ ])
1427
+
1428
+ /**
1429
+ * Apply a single attribute to a DOM element.
1430
+ * Attribute name is the DOM attribute name directly (e.g., 'href', 'aria-label').
1431
+ */
1432
+ function applyAttributeToElement(
1433
+ element: HTMLElement,
1434
+ attributeName: string,
1435
+ value: string | boolean | number | undefined,
1436
+ ): void {
1437
+ // Handle boolean attributes
1438
+ if (typeof value === 'boolean' || BOOLEAN_ATTRIBUTES.has(attributeName)) {
1439
+ const boolVal = value === true || value === 'true'
1440
+ if (boolVal) {
1441
+ element.setAttribute(attributeName, '')
1442
+ } else {
1443
+ element.removeAttribute(attributeName)
1444
+ }
1445
+ return
1446
+ }
1447
+
1448
+ // Handle undefined/empty - remove attribute
1449
+ if (value === undefined || value === '') {
1450
+ element.removeAttribute(attributeName)
1451
+ return
1452
+ }
1453
+
1454
+ // Set the attribute
1455
+ element.setAttribute(attributeName, String(value))
1456
+ }
1457
+
1458
+ /**
1459
+ * Apply all attributes from a flat Record<string, Attribute> to an element.
1460
+ */
1461
+ function applyAttributesToElement(element: HTMLElement, attributes: Record<string, Attribute>): void {
1462
+ for (const [attrName, attr] of Object.entries(attributes)) {
1463
+ applyAttributeToElement(element, attrName, attr.value)
1464
+ }
1465
+ }
1466
+
1467
+ /**
1468
+ * Check if any attributes have changed from original.
1469
+ */
1470
+ function checkAttributesDirty(original: Record<string, Attribute>, current: Record<string, Attribute>): boolean {
1471
+ const allKeys = new Set([...Object.keys(original), ...Object.keys(current)])
1472
+ for (const key of allKeys) {
1473
+ if (original[key]?.value !== current[key]?.value) {
1474
+ return true
1475
+ }
1476
+ }
1477
+ return false
1478
+ }
1479
+
1480
+ /**
1481
+ * Build attribute change payload for API from original and new attributes.
1482
+ * Includes per-attribute source info from the Attribute objects.
1483
+ */
1484
+ function buildAttributeChangePayload(
1485
+ original: Record<string, Attribute>,
1486
+ current: Record<string, Attribute>,
1487
+ ): AttributeChangePayload[] {
1488
+ const changes: AttributeChangePayload[] = []
1489
+ const allKeys = new Set([...Object.keys(original), ...Object.keys(current)])
1490
+
1491
+ for (const attrName of allKeys) {
1492
+ const origAttr = original[attrName]
1493
+ const currAttr = current[attrName]
1494
+
1495
+ if (origAttr?.value !== currAttr?.value) {
1496
+ // Use source info from the original attribute (where it was defined)
1497
+ const sourceAttr = origAttr || currAttr
1498
+ changes.push({
1499
+ attributeName: attrName,
1500
+ oldValue: origAttr?.value,
1501
+ newValue: currAttr?.value,
1502
+ sourcePath: sourceAttr?.sourcePath,
1503
+ sourceLine: sourceAttr?.sourceLine,
1504
+ sourceSnippet: sourceAttr?.sourceSnippet,
1505
+ })
1506
+ }
1507
+ }
1508
+
1509
+ return changes
1510
+ }