astro-tractstack 2.1.2 → 2.2.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 (131) hide show
  1. package/README.md +54 -266
  2. package/bin/create-tractstack.js +9 -6
  3. package/dist/index.js +109 -71
  4. package/package.json +4 -2
  5. package/templates/css/custom.css +5 -0
  6. package/templates/custom/minimal/CodeHook.astro +1 -0
  7. package/templates/custom/with-examples/CodeHook.astro +1 -0
  8. package/templates/icons/code.svg +18 -0
  9. package/templates/icons/li.svg +4 -0
  10. package/templates/icons/link.svg +22 -0
  11. package/templates/icons/p.svg +3 -0
  12. package/templates/src/client/app.js +80 -1
  13. package/templates/src/components/Footer.astro +1 -1
  14. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +6 -6
  15. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +3 -3
  16. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +1 -1
  17. package/templates/src/components/codehooks/ListContentSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/ProductCardSetup.tsx +1 -1
  19. package/templates/src/components/codehooks/ProductGridSetup.tsx +2 -2
  20. package/templates/src/components/codehooks/SandboxRegisterForm.tsx +3 -3
  21. package/templates/src/components/compositor/Compositor.tsx +25 -9
  22. package/templates/src/components/compositor/Node.tsx +168 -496
  23. package/templates/src/components/compositor/PanelVisibilityWrapper.tsx +1 -0
  24. package/templates/src/components/compositor/elements/SignUp.tsx +1 -1
  25. package/templates/src/components/compositor/elements/YouTubeWrapper.tsx +2 -0
  26. package/templates/src/components/compositor/nodes/CreativePane.tsx +262 -0
  27. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +4 -6
  28. package/templates/src/components/compositor/nodes/GridLayout.tsx +4 -2
  29. package/templates/src/components/compositor/nodes/Markdown.tsx +18 -3
  30. package/templates/src/components/compositor/nodes/Pane.tsx +11 -5
  31. package/templates/src/components/compositor/nodes/RenderChildren.tsx +1 -1
  32. package/templates/src/components/compositor/nodes/tagElements/NodeAnchorComponent.tsx +5 -5
  33. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag.tsx +90 -42
  34. package/templates/src/components/compositor/nodes/tagElements/NodeImg.tsx +2 -0
  35. package/templates/src/components/compositor/nodes/tagElements/NodeText.tsx +27 -1
  36. package/templates/src/components/compositor/preview/PaneSnapshotGenerator.tsx +10 -8
  37. package/templates/src/components/compositor/tools/NodeOverlay.tsx +224 -0
  38. package/templates/src/components/compositor/tools/PaneOverlay.tsx +122 -0
  39. package/templates/src/components/edit/Header.tsx +68 -9
  40. package/templates/src/components/edit/PanelSwitch.tsx +42 -4
  41. package/templates/src/components/edit/SettingsPanel.tsx +2 -3
  42. package/templates/src/components/edit/ToolMode.tsx +1 -31
  43. package/templates/src/components/edit/pane/AddPanePanel_break.tsx +2 -2
  44. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +1 -1
  45. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +193 -659
  46. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +15 -82
  47. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +95 -45
  48. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +137 -49
  49. package/templates/src/components/edit/pane/RestylePaneModal.tsx +1 -1
  50. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +375 -0
  51. package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +1 -23
  52. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +327 -0
  53. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +267 -0
  54. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +371 -0
  55. package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +201 -76
  56. package/templates/src/components/edit/pane/steps/CreativeInjectStep.tsx +141 -0
  57. package/templates/src/components/edit/panels/CreativeImagePanel.tsx +435 -0
  58. package/templates/src/components/edit/panels/CreativeLinkPanel.tsx +110 -0
  59. package/templates/src/components/edit/panels/StyleCodeHookPanel.tsx +1 -1
  60. package/templates/src/components/edit/panels/StyleParentPanel.tsx +118 -126
  61. package/templates/src/components/edit/panels/StyleParentPanel_add.tsx +3 -2
  62. package/templates/src/components/edit/panels/StyleParentPanel_deleteLayer.tsx +1 -0
  63. package/templates/src/components/edit/panels/StyleParentPanel_remove.tsx +3 -1
  64. package/templates/src/components/edit/panels/StyleParentPanel_update.tsx +3 -1
  65. package/templates/src/components/edit/panels/StyleWidgetPanel.tsx +1 -1
  66. package/templates/src/components/edit/state/SaveModal.tsx +19 -787
  67. package/templates/src/components/edit/state/SaveToLibraryModal.tsx +2 -2
  68. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_menu.tsx +1 -1
  69. package/templates/src/components/edit/widgets/BunnyWidget.tsx +5 -5
  70. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +1 -1
  71. package/templates/src/components/edit/widgets/SignupWidget.tsx +1 -1
  72. package/templates/src/components/fields/ActionBuilderTimeSelector.tsx +1 -1
  73. package/templates/src/components/fields/ArtpackImage.tsx +11 -3
  74. package/templates/src/components/fields/BackgroundImage.tsx +8 -0
  75. package/templates/src/components/fields/BackgroundImageWrapper.tsx +15 -9
  76. package/templates/src/components/fields/ImageUpload.tsx +6 -0
  77. package/templates/src/components/form/ActionBuilderField.tsx +15 -5
  78. package/templates/src/components/form/ActionBuilderSlugSelector.tsx +1 -1
  79. package/templates/src/components/form/ColorPicker.tsx +1 -1
  80. package/templates/src/components/form/EnumSelect.tsx +1 -1
  81. package/templates/src/components/form/NumberInput.tsx +1 -1
  82. package/templates/src/components/form/StringArrayInput.tsx +1 -1
  83. package/templates/src/components/form/StringInput.tsx +1 -1
  84. package/templates/src/components/form/UnsavedChangesBar.tsx +1 -1
  85. package/templates/src/components/form/advanced/APIConfigSection.tsx +2 -2
  86. package/templates/src/components/form/advanced/AuthConfigSection.tsx +2 -2
  87. package/templates/src/components/profile/ProfileCreate.tsx +1 -1
  88. package/templates/src/components/profile/ProfileEdit.tsx +1 -1
  89. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +2 -2
  90. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +1 -1
  91. package/templates/src/components/storykeep/controls/content/ContentSummary.tsx +2 -2
  92. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +1 -1
  93. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +6 -6
  94. package/templates/src/components/storykeep/controls/content/MenuForm.tsx +1 -1
  95. package/templates/src/components/storykeep/controls/content/PaneTable.tsx +358 -0
  96. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -1
  97. package/templates/src/constants/prompts.json +18 -10
  98. package/templates/src/constants.ts +3 -0
  99. package/templates/src/hooks/usePaneFragments.ts +60 -0
  100. package/templates/src/lib/session.ts +71 -16
  101. package/templates/src/pages/[...slug].astro +5 -46
  102. package/templates/src/pages/api/css.ts +149 -0
  103. package/templates/src/pages/context/[...contextSlug].astro +1 -0
  104. package/templates/src/pages/maint.astro +1 -1
  105. package/templates/src/pages/storykeep/login.astro +2 -2
  106. package/templates/src/stores/nodes.ts +162 -49
  107. package/templates/src/stores/orphanAnalysis.ts +6 -30
  108. package/templates/src/stores/previews.ts +7 -0
  109. package/templates/src/stores/storykeep.ts +0 -8
  110. package/templates/src/types/compositorTypes.ts +53 -10
  111. package/templates/src/utils/compositor/aiGeneration.ts +93 -0
  112. package/templates/src/utils/compositor/allowInsert.ts +2 -0
  113. package/templates/src/utils/compositor/htmlAst.ts +704 -0
  114. package/templates/src/utils/compositor/nodesHelper.ts +281 -102
  115. package/templates/src/utils/compositor/savePipeline.ts +893 -0
  116. package/templates/src/utils/etl/index.ts +3 -0
  117. package/templates/src/utils/etl/transformer.ts +10 -0
  118. package/templates/src/utils/helpers.ts +101 -0
  119. package/utils/inject-files.ts +100 -62
  120. package/templates/icons/text.svg +0 -6
  121. package/templates/src/components/compositor/NodeWithGuid.tsx +0 -69
  122. package/templates/src/components/compositor/nodes/GridLayout_eraser.tsx +0 -33
  123. package/templates/src/components/compositor/nodes/Markdown_eraser.tsx +0 -56
  124. package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +0 -269
  125. package/templates/src/components/compositor/nodes/Pane_eraser.tsx +0 -186
  126. package/templates/src/components/compositor/nodes/Pane_layout.tsx +0 -79
  127. package/templates/src/components/compositor/nodes/tagElements/NodeA_eraser.tsx +0 -26
  128. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_eraser.tsx +0 -61
  129. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_insert.tsx +0 -120
  130. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_settings.tsx +0 -62
  131. package/templates/src/components/compositor/nodes/tagElements/NodeButton_eraser.tsx +0 -26
@@ -0,0 +1,704 @@
1
+ import { ulid } from 'ulid';
2
+ import type {
3
+ CreativePanePayload,
4
+ HtmlAstNode,
5
+ EditableElementMetadata,
6
+ } from '@/types/compositorTypes';
7
+
8
+ const VERBOSE = false;
9
+
10
+ const logger = {
11
+ log: (...args: any[]) => VERBOSE && console.log('[htmlAst]', ...args),
12
+ error: (...args: any[]) => console.error('[htmlAst]', ...args),
13
+ group: (label: string) => VERBOSE && console.group(`[htmlAst] ${label}`),
14
+ groupEnd: () => VERBOSE && console.groupEnd(),
15
+ };
16
+
17
+ class StyleRegistry {
18
+ private classMap: Record<string, string>;
19
+ private ruleMap: Record<string, string>;
20
+
21
+ constructor(
22
+ classMap: Record<string, string>,
23
+ ruleMap: Record<string, string>
24
+ ) {
25
+ this.classMap = classMap;
26
+ this.ruleMap = ruleMap;
27
+ }
28
+
29
+ lookupClass(className: string): string {
30
+ return this.classMap[className] || className;
31
+ }
32
+
33
+ getRuleBody(className: string): string | undefined {
34
+ const hashString = this.classMap[className];
35
+ if (!hashString) return undefined;
36
+
37
+ const hashes = hashString.split(/\s+/);
38
+
39
+ for (const hash of hashes) {
40
+ if (this.ruleMap[hash]) {
41
+ return this.ruleMap[hash];
42
+ }
43
+ }
44
+
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export async function htmlToHtmlAst(
50
+ html: string,
51
+ userCss: string
52
+ ): Promise<CreativePanePayload> {
53
+ logger.group(`htmlToHtmlAst Trace:`);
54
+ logger.log('Input HTML:', html);
55
+ let apiResult = {
56
+ css: '',
57
+ viewportCss: { xs: '', md: '', xl: '' },
58
+ classMap: {} as Record<string, string>,
59
+ ruleMap: {} as Record<string, string>,
60
+ };
61
+
62
+ try {
63
+ const res = await fetch('/api/css', {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ html, css: userCss }),
67
+ });
68
+
69
+ if (res.ok) {
70
+ const data = await res.json();
71
+ if (data.success) {
72
+ apiResult = {
73
+ css: data.css,
74
+ viewportCss: data.viewportCss,
75
+ classMap: data.classMap,
76
+ ruleMap: data.ruleMap,
77
+ };
78
+ }
79
+ } else {
80
+ logger.error('CSS API Error:', res.status, res.statusText);
81
+ }
82
+ } catch (e) {
83
+ logger.error('Tailwind API Fetch Failed', e);
84
+ }
85
+
86
+ // Initialize the registry with the maps provided by the server
87
+ const styleRegistry = new StyleRegistry(
88
+ apiResult.classMap,
89
+ apiResult.ruleMap
90
+ );
91
+ const editableRegistry: Record<string, EditableElementMetadata> = {};
92
+
93
+ // Parse HTML
94
+ const parser = new DOMParser();
95
+ const doc = parser.parseFromString(html, 'text/html');
96
+
97
+ // Walk the DOM to build the tree, applying hashes via the registry
98
+ const tree = Array.from(doc.body.children).map((child) =>
99
+ processNode(child as HTMLElement, styleRegistry, editableRegistry)
100
+ );
101
+
102
+ return {
103
+ css: apiResult.css,
104
+ viewportCss: apiResult.viewportCss,
105
+ tree,
106
+ editableElements: editableRegistry,
107
+ };
108
+ }
109
+
110
+ export function rehydrateChildrenFromHtml(html: string): HtmlAstNode[] {
111
+ logger.group('rehydrateChildrenFromHtml');
112
+ const editableRegistry: Record<string, EditableElementMetadata> = {};
113
+
114
+ const div = document.createElement('div');
115
+ div.innerHTML = html;
116
+
117
+ const nodes = Array.from(div.childNodes)
118
+ .map((child) => {
119
+ if (child.nodeType === Node.ELEMENT_NODE)
120
+ return processRehydratedNode(child as HTMLElement, editableRegistry);
121
+ if (child.nodeType === Node.TEXT_NODE && child.textContent)
122
+ return { tag: 'text', text: child.textContent };
123
+ return null;
124
+ })
125
+ .filter((n): n is HtmlAstNode => n !== null);
126
+
127
+ logger.log(
128
+ `Rehydration Complete. Assets synced: ${Object.keys(editableRegistry).length}`
129
+ );
130
+ logger.groupEnd();
131
+ return nodes;
132
+ }
133
+
134
+ export function extractTextFromAst(tree: HtmlAstNode[]): string {
135
+ const blockTags = new Set([
136
+ 'p',
137
+ 'h1',
138
+ 'h2',
139
+ 'h3',
140
+ 'h4',
141
+ 'h5',
142
+ 'h6',
143
+ 'div',
144
+ 'section',
145
+ 'article',
146
+ 'li',
147
+ 'blockquote',
148
+ 'ul',
149
+ 'ol',
150
+ 'main',
151
+ 'header',
152
+ 'footer',
153
+ 'tr',
154
+ 'table',
155
+ 'form',
156
+ 'nav',
157
+ 'dt',
158
+ 'dd',
159
+ ]);
160
+ function traverse(nodes: HtmlAstNode[]): string {
161
+ return nodes
162
+ .map((node) => {
163
+ if (node.tag === 'text') return node.text?.replace(/\s+/g, ' ') || '';
164
+ if (node.tag === 'br') return '\n';
165
+ const childText = node.children ? traverse(node.children) : '';
166
+ return blockTags.has(node.tag) ? `\n${childText}\n` : childText;
167
+ })
168
+ .join('');
169
+ }
170
+ return traverse(tree)
171
+ .replace(/[ \t]+/g, ' ')
172
+ .replace(/\n\s+/g, '\n')
173
+ .replace(/\s+\n/g, '\n')
174
+ .replace(/\n+/g, '\n')
175
+ .trim();
176
+ }
177
+
178
+ /**
179
+ * Serializes the AST back to a raw HTML string to feed the pipeline.
180
+ * Preserves data-ast-id to maintain node identity during regeneration.
181
+ */
182
+ function serializeAstToHtml(nodes: HtmlAstNode[]): string {
183
+ const voidTags = new Set([
184
+ 'area',
185
+ 'base',
186
+ 'br',
187
+ 'col',
188
+ 'embed',
189
+ 'hr',
190
+ 'img',
191
+ 'input',
192
+ 'link',
193
+ 'meta',
194
+ 'param',
195
+ 'source',
196
+ 'track',
197
+ 'wbr',
198
+ ]);
199
+
200
+ return nodes
201
+ .map((node) => {
202
+ if (node.tag === 'text') return node.text || '';
203
+
204
+ const attrs = Object.entries(node.attrs || {})
205
+ .map(([key, value]) => `${key}="${value}"`)
206
+ .join(' ');
207
+
208
+ // Crucial: Inject the ID back so the pipeline re-claims it
209
+ const idAttr = node.id ? `data-ast-id="${node.id}"` : '';
210
+ const space = attrs || idAttr ? ' ' : '';
211
+ const combinedAttrs = [idAttr, attrs].filter(Boolean).join(' ');
212
+
213
+ const openTag = `<${node.tag}${space}${combinedAttrs}>`;
214
+
215
+ if (voidTags.has(node.tag)) return openTag;
216
+
217
+ const children = node.children ? serializeAstToHtml(node.children) : '';
218
+ return `${openTag}${children}</${node.tag}>`;
219
+ })
220
+ .join('');
221
+ }
222
+
223
+ /**
224
+ * Scanner: Detects Assets based on Tags or Background Image URLs.
225
+ */
226
+ function extractMetadataFromNode(
227
+ el: HTMLElement,
228
+ astId: string,
229
+ registry?: StyleRegistry
230
+ ): EditableElementMetadata | null {
231
+ logger.group(`extractMetadataFromNode Trace: ${astId}`);
232
+ const tagName = el.tagName.toLowerCase();
233
+ const classList = Array.from(el.classList);
234
+ logger.log(`Target: <${tagName}> | Classes:`, classList);
235
+
236
+ // Helper to extract common identity attributes
237
+ const getIdentity = () => ({
238
+ fileId: el.getAttribute('data-file-id') || undefined,
239
+ collection: el.getAttribute('data-collection') || undefined,
240
+ image: el.getAttribute('data-image') || undefined,
241
+ });
242
+
243
+ // 1. Tag-based Assets
244
+ if (tagName === 'img') {
245
+ const imgEl = el as HTMLImageElement;
246
+ const meta: EditableElementMetadata = {
247
+ astId,
248
+ tagName,
249
+ src: imgEl.getAttribute('src') || '',
250
+ srcSet: imgEl.getAttribute('srcset') || undefined,
251
+ alt: imgEl.getAttribute('alt') || '',
252
+ ...getIdentity(),
253
+ };
254
+ logger.log(`[Discovery] MATCH: <img> tag`, meta);
255
+ logger.groupEnd();
256
+ return meta;
257
+ }
258
+
259
+ if (tagName === 'a') {
260
+ const anchorEl = el as HTMLAnchorElement;
261
+ const meta: EditableElementMetadata = {
262
+ astId,
263
+ tagName,
264
+ href: anchorEl.getAttribute('href') || '',
265
+ // Links can now carry button-like payloads (e.g. video triggers)
266
+ buttonPayload: {
267
+ callbackPayload: el.getAttribute('data-callback') || '',
268
+ isExternalUrl: el.getAttribute('data-external') === 'true',
269
+ bunnyPayload: el.getAttribute('data-bunny-payload')
270
+ ? JSON.parse(el.getAttribute('data-bunny-payload')!)
271
+ : undefined,
272
+ },
273
+ };
274
+ logger.log(`[Discovery] MATCH: <a> tag`, meta);
275
+ logger.groupEnd();
276
+ return meta;
277
+ }
278
+
279
+ if (tagName === 'button') {
280
+ const meta: EditableElementMetadata = {
281
+ astId,
282
+ tagName,
283
+ buttonPayload: {
284
+ callbackPayload: el.getAttribute('data-callback') || '',
285
+ isExternalUrl: el.getAttribute('data-external') === 'true',
286
+ bunnyPayload: el.getAttribute('data-bunny-payload')
287
+ ? JSON.parse(el.getAttribute('data-bunny-payload')!)
288
+ : undefined,
289
+ },
290
+ };
291
+ logger.log(`[Discovery] MATCH: <button> tag`, meta);
292
+ logger.groupEnd();
293
+ return meta;
294
+ }
295
+
296
+ // 2. Background Image Detection (URL only)
297
+ let bgImageUrl = '';
298
+
299
+ // Check Inline Style
300
+ const inlineBg = el.style.backgroundImage;
301
+ if (inlineBg && inlineBg !== 'none') {
302
+ const match = inlineBg.match(/url\(["']?(.*?)["']?\)/);
303
+ if (match) {
304
+ bgImageUrl = match[1];
305
+ logger.log(`[Discovery] Found inline background-image: ${bgImageUrl}`);
306
+ }
307
+ }
308
+
309
+ // Check Registry for class-based background image URLs
310
+ if (!bgImageUrl && registry) {
311
+ for (const cls of classList) {
312
+ const ruleBody = registry.getRuleBody(cls);
313
+ if (ruleBody) {
314
+ const urlMatch = ruleBody.match(
315
+ /background-image:\s*url\(["']?(.*?)["']?\)/
316
+ );
317
+ if (urlMatch) {
318
+ bgImageUrl = urlMatch[1];
319
+ logger.log(
320
+ `[Discovery] SUCCESS: Found background image in class "${cls}": ${bgImageUrl}`
321
+ );
322
+ break;
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ if (bgImageUrl) {
329
+ const meta: EditableElementMetadata = {
330
+ astId,
331
+ tagName,
332
+ src: bgImageUrl,
333
+ isCssBackground: true,
334
+ ...getIdentity(),
335
+ };
336
+ logger.groupEnd();
337
+ return meta;
338
+ }
339
+
340
+ logger.log(`[Discovery] REJECTED: No managed capabilities found.`);
341
+ logger.groupEnd();
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * processNode: The primary entry point for converting a live DOM element into an AST node.
347
+ * Strictly whitelists allowed attributes and sanitizes text content to prevent
348
+ * "sloppy" storage and ephemeral attribute leakage.
349
+ */
350
+ function processNode(
351
+ el: HTMLElement,
352
+ registry: StyleRegistry,
353
+ editableRegistry: Record<string, EditableElementMetadata>
354
+ ): HtmlAstNode {
355
+ const tagName = el.tagName.toLowerCase();
356
+
357
+ const isTextEditable = [
358
+ 'p',
359
+ 'h1',
360
+ 'h2',
361
+ 'h3',
362
+ 'h4',
363
+ 'h5',
364
+ 'h6',
365
+ 'li',
366
+ ].includes(tagName);
367
+
368
+ const metadataCheck = extractMetadataFromNode(el, 'temp', registry);
369
+ const isIdentifiableAsset = !!metadataCheck;
370
+
371
+ const existingId = el.getAttribute('data-ast-id');
372
+ let id: string | undefined = existingId || undefined;
373
+
374
+ if (!id && (isTextEditable || isIdentifiableAsset)) {
375
+ id = `ast-${ulid().toLowerCase()}`;
376
+ }
377
+
378
+ if (id && isIdentifiableAsset) {
379
+ const finalMetadata = extractMetadataFromNode(el, id, registry);
380
+ if (finalMetadata) {
381
+ editableRegistry[id] = finalMetadata;
382
+ }
383
+ }
384
+
385
+ const attrs: Record<string, string> = {};
386
+ const allowedAttributes = [
387
+ 'style',
388
+ 'src',
389
+ 'srcset',
390
+ 'alt',
391
+ 'href',
392
+ 'target',
393
+ 'type',
394
+ 'value',
395
+ 'placeholder',
396
+ 'data-file-id',
397
+ 'data-collection',
398
+ 'data-image',
399
+ 'data-callback',
400
+ 'data-external',
401
+ 'data-bunny-payload',
402
+ ];
403
+
404
+ if (el.hasAttributes()) {
405
+ for (const attr of Array.from(el.attributes)) {
406
+ if (attr.name === 'class') {
407
+ const tokens = attr.value
408
+ .split(/\s+/)
409
+ .flatMap((c) => registry.lookupClass(c).split(/\s+/));
410
+ attrs['class'] = Array.from(new Set(tokens.filter(Boolean))).join(' ');
411
+ } else if (allowedAttributes.includes(attr.name)) {
412
+ logger.log(
413
+ `Keeping attribute on <${tagName}>:`,
414
+ attr.name,
415
+ '=',
416
+ attr.value
417
+ );
418
+ attrs[attr.name] = attr.value;
419
+ }
420
+ }
421
+ }
422
+
423
+ const children: HtmlAstNode[] = Array.from(el.childNodes)
424
+ .map((child) => {
425
+ if (child.nodeType === Node.ELEMENT_NODE) {
426
+ return processNode(child as HTMLElement, registry, editableRegistry);
427
+ }
428
+ if (child.nodeType === Node.TEXT_NODE) {
429
+ const cleanedText = child.textContent?.replace(/\s+/g, ' ');
430
+ if (cleanedText) {
431
+ return {
432
+ tag: 'text',
433
+ text: cleanedText,
434
+ id: isTextEditable ? `ast-${ulid().toLowerCase()}` : undefined,
435
+ };
436
+ }
437
+ }
438
+ return null;
439
+ })
440
+ .filter((n): n is HtmlAstNode => n !== null);
441
+
442
+ return { tag: tagName, attrs, children, id };
443
+ }
444
+
445
+ /**
446
+ * processRehydratedNode: Rebuilds an AST node from a static HTML snippet.
447
+ * Shares the same strict attribute whitelist and text sanitization as processNode.
448
+ */
449
+ function processRehydratedNode(
450
+ el: HTMLElement,
451
+ editableRegistry: Record<string, EditableElementMetadata>
452
+ ): HtmlAstNode {
453
+ const tagName = el.tagName.toLowerCase();
454
+ const id = el.getAttribute('data-ast-id') || undefined;
455
+
456
+ if (id) {
457
+ const metadata = extractMetadataFromNode(el, id);
458
+ if (metadata) {
459
+ editableRegistry[id] = metadata;
460
+ }
461
+ } else {
462
+ const capabilityMeta = extractMetadataFromNode(
463
+ el,
464
+ `rehydrated-${ulid().toLowerCase()}`
465
+ );
466
+ if (capabilityMeta) {
467
+ editableRegistry[capabilityMeta.astId] = capabilityMeta;
468
+ }
469
+ }
470
+
471
+ const attrs: Record<string, string> = {};
472
+ const allowedAttributes = [
473
+ 'class',
474
+ 'style',
475
+ 'src',
476
+ 'srcset',
477
+ 'alt',
478
+ 'href',
479
+ 'target',
480
+ 'type',
481
+ 'value',
482
+ 'placeholder',
483
+ 'data-file-id',
484
+ 'data-collection',
485
+ 'data-image',
486
+ 'data-callback',
487
+ 'data-external',
488
+ 'data-bunny-payload',
489
+ ];
490
+
491
+ if (el.hasAttributes()) {
492
+ for (const attr of Array.from(el.attributes)) {
493
+ if (allowedAttributes.includes(attr.name)) {
494
+ attrs[attr.name] = attr.value;
495
+ }
496
+ }
497
+ }
498
+
499
+ const children: HtmlAstNode[] = Array.from(el.childNodes)
500
+ .map((child) => {
501
+ if (child.nodeType === Node.ELEMENT_NODE)
502
+ return processRehydratedNode(child as HTMLElement, editableRegistry);
503
+ if (child.nodeType === Node.TEXT_NODE) {
504
+ const cleanedText = child.textContent?.replace(/\s+/g, ' ');
505
+ if (cleanedText) {
506
+ return { tag: 'text', text: cleanedText };
507
+ }
508
+ }
509
+ return null;
510
+ })
511
+ .filter((n): n is HtmlAstNode => n !== null);
512
+
513
+ return { tag: tagName, attrs, children, id };
514
+ }
515
+
516
+ /**
517
+ * atomic update logic for Creative Panes.
518
+ * 1. Patches the AST (for src/href/callback).
519
+ * 2. Patches the CSS (for background images).
520
+ * 3. Re-runs the pipeline to generate fresh viewport CSS and registry.
521
+ */
522
+ export async function regenerateCreativePane(
523
+ originalPayload: CreativePanePayload,
524
+ astId: string,
525
+ updates: Partial<EditableElementMetadata>
526
+ ): Promise<CreativePanePayload> {
527
+ const deepClone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
528
+ const tree = deepClone(originalPayload.tree);
529
+ let css = originalPayload.css;
530
+
531
+ const findAndPatchNode = (nodes: HtmlAstNode[]): HtmlAstNode | null => {
532
+ for (const node of nodes) {
533
+ if (node.id === astId) return node;
534
+ if (node.children) {
535
+ const found = findAndPatchNode(node.children);
536
+ if (found) return found;
537
+ }
538
+ }
539
+ return null;
540
+ };
541
+
542
+ const targetNode = findAndPatchNode(tree);
543
+
544
+ if (targetNode) {
545
+ if (!targetNode.attrs) targetNode.attrs = {};
546
+
547
+ // 1. Identity Persistence (Uploaded Files)
548
+ if (updates.fileId) {
549
+ targetNode.attrs['data-file-id'] = updates.fileId;
550
+ } else if (updates.src !== undefined) {
551
+ delete targetNode.attrs['data-file-id'];
552
+ }
553
+
554
+ // 2. Identity Persistence (Artpack)
555
+ if (updates.collection) {
556
+ targetNode.attrs['data-collection'] = updates.collection;
557
+ } else if (updates.collection === null) {
558
+ delete targetNode.attrs['data-collection'];
559
+ }
560
+ if (updates.image) {
561
+ targetNode.attrs['data-image'] = updates.image;
562
+ } else if (updates.image === null) {
563
+ delete targetNode.attrs['data-image'];
564
+ }
565
+
566
+ if (updates.src && updates.tagName === 'img') {
567
+ targetNode.attrs.src = updates.src;
568
+ if (updates.srcSet) targetNode.attrs.srcset = updates.srcSet;
569
+ }
570
+ if (updates.alt) {
571
+ targetNode.attrs.alt = updates.alt;
572
+ }
573
+ if (updates.href && updates.tagName === 'a') {
574
+ targetNode.attrs.href = updates.href;
575
+ }
576
+
577
+ // 3. Button Payload Persistence (Buttons & Links)
578
+ if (updates.buttonPayload) {
579
+ const { callbackPayload, isExternalUrl, bunnyPayload } =
580
+ updates.buttonPayload;
581
+
582
+ if (callbackPayload) targetNode.attrs['data-callback'] = callbackPayload;
583
+ if (isExternalUrl !== undefined)
584
+ targetNode.attrs['data-external'] = String(isExternalUrl);
585
+ if (bunnyPayload)
586
+ targetNode.attrs['data-bunny-payload'] = JSON.stringify(bunnyPayload);
587
+ }
588
+
589
+ // 4. Background Image Persistence (The Fix)
590
+ if (updates.isCssBackground && updates.src) {
591
+ // Always use inline style for user-edited background images.
592
+ // This is robust, handles override specificity automatically,
593
+ // and avoids the complexity of patching hashed CSS rules.
594
+ let currentStyle = targetNode.attrs['style'] || '';
595
+
596
+ // Remove existing background-image property if present to avoid duplicates
597
+ currentStyle = currentStyle
598
+ .replace(/background-image:\s*url\([^)]+\);?/gi, '')
599
+ .trim();
600
+
601
+ // Append the new background image
602
+ const newBgRule = `background-image: url('${updates.src}');`;
603
+ targetNode.attrs['style'] = currentStyle
604
+ ? `${currentStyle} ${newBgRule}`
605
+ : newBgRule;
606
+ }
607
+ }
608
+
609
+ const html = serializeAstToHtml(tree);
610
+ const newPayload = await htmlToHtmlAst(html, css);
611
+
612
+ if (originalPayload.editableElements) {
613
+ for (const [id, originalMeta] of Object.entries(
614
+ originalPayload.editableElements
615
+ )) {
616
+ if (newPayload.editableElements[id]) {
617
+ if (originalMeta.base64Data) {
618
+ newPayload.editableElements[id].base64Data = originalMeta.base64Data;
619
+ }
620
+ if (originalMeta.fileId && !newPayload.editableElements[id].fileId) {
621
+ newPayload.editableElements[id].fileId = originalMeta.fileId;
622
+ }
623
+ }
624
+ }
625
+ }
626
+
627
+ if (newPayload.editableElements[astId]) {
628
+ newPayload.editableElements[astId] = {
629
+ ...newPayload.editableElements[astId],
630
+ ...updates,
631
+ };
632
+ }
633
+
634
+ return newPayload;
635
+ }
636
+
637
+ export async function scanAndReplaceBase64(
638
+ payload: CreativePanePayload,
639
+ uploadFn: (
640
+ base64: string
641
+ ) => Promise<{ fileId: string; src: string; srcSet?: string }>
642
+ ): Promise<CreativePanePayload> {
643
+ logger.group('scanAndReplaceBase64');
644
+ let currentPayload = JSON.parse(
645
+ JSON.stringify(payload)
646
+ ) as CreativePanePayload;
647
+ const elements = currentPayload.editableElements || {};
648
+ let modifiedCount = 0;
649
+
650
+ for (const [astId, meta] of Object.entries(elements)) {
651
+ let dataToUpload = meta.base64Data;
652
+ if (!dataToUpload && meta.src && meta.src.startsWith('data:')) {
653
+ logger.log(
654
+ `[Base64] Recovering metadata-less base64 from src for ${astId}`
655
+ );
656
+ dataToUpload = meta.src;
657
+ }
658
+
659
+ if (dataToUpload) {
660
+ logger.log(`[Base64] Found pending upload for element ${astId}`);
661
+ try {
662
+ const result = await uploadFn(dataToUpload);
663
+ logger.log(
664
+ `[Base64] Upload successful for ${astId}. FileID: ${result.fileId}`
665
+ );
666
+
667
+ currentPayload = await regenerateCreativePane(currentPayload, astId, {
668
+ src: result.src,
669
+ srcSet: result.srcSet,
670
+ fileId: result.fileId,
671
+ base64Data: undefined,
672
+ tagName: meta.tagName,
673
+ isCssBackground: meta.isCssBackground,
674
+ });
675
+ modifiedCount++;
676
+ } catch (err) {
677
+ logger.error(`[Base64] Failed to upload asset for ${astId}`, err);
678
+ }
679
+ }
680
+ }
681
+
682
+ logger.log(`Scan complete. Modified ${modifiedCount} elements.`);
683
+ logger.groupEnd();
684
+ return currentPayload;
685
+ }
686
+
687
+ export function extractFileIdsFromAst(payload: CreativePanePayload): string[] {
688
+ if (VERBOSE) logger.group('extractFileIdsFromAst');
689
+ const fileIds = new Set<string>();
690
+ const elements = payload.editableElements || {};
691
+
692
+ Object.values(elements).forEach((meta) => {
693
+ if (meta.fileId) {
694
+ fileIds.add(meta.fileId);
695
+ }
696
+ });
697
+
698
+ const results = Array.from(fileIds);
699
+ if (VERBOSE) {
700
+ logger.log(`Found ${results.length} unique file IDs:`, results);
701
+ logger.groupEnd();
702
+ }
703
+ return results;
704
+ }