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.
- package/README.md +54 -266
- package/bin/create-tractstack.js +9 -6
- package/dist/index.js +109 -71
- package/package.json +4 -2
- package/templates/css/custom.css +5 -0
- package/templates/custom/minimal/CodeHook.astro +1 -0
- package/templates/custom/with-examples/CodeHook.astro +1 -0
- package/templates/icons/code.svg +18 -0
- package/templates/icons/li.svg +4 -0
- package/templates/icons/link.svg +22 -0
- package/templates/icons/p.svg +3 -0
- package/templates/src/client/app.js +80 -1
- package/templates/src/components/Footer.astro +1 -1
- package/templates/src/components/codehooks/BunnyVideoSetup.tsx +6 -6
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +3 -3
- package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +1 -1
- package/templates/src/components/codehooks/ListContentSetup.tsx +2 -2
- package/templates/src/components/codehooks/ProductCardSetup.tsx +1 -1
- package/templates/src/components/codehooks/ProductGridSetup.tsx +2 -2
- package/templates/src/components/codehooks/SandboxRegisterForm.tsx +3 -3
- package/templates/src/components/compositor/Compositor.tsx +25 -9
- package/templates/src/components/compositor/Node.tsx +168 -496
- package/templates/src/components/compositor/PanelVisibilityWrapper.tsx +1 -0
- package/templates/src/components/compositor/elements/SignUp.tsx +1 -1
- package/templates/src/components/compositor/elements/YouTubeWrapper.tsx +2 -0
- package/templates/src/components/compositor/nodes/CreativePane.tsx +262 -0
- package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +4 -6
- package/templates/src/components/compositor/nodes/GridLayout.tsx +4 -2
- package/templates/src/components/compositor/nodes/Markdown.tsx +18 -3
- package/templates/src/components/compositor/nodes/Pane.tsx +11 -5
- package/templates/src/components/compositor/nodes/RenderChildren.tsx +1 -1
- package/templates/src/components/compositor/nodes/tagElements/NodeAnchorComponent.tsx +5 -5
- package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag.tsx +90 -42
- package/templates/src/components/compositor/nodes/tagElements/NodeImg.tsx +2 -0
- package/templates/src/components/compositor/nodes/tagElements/NodeText.tsx +27 -1
- package/templates/src/components/compositor/preview/PaneSnapshotGenerator.tsx +10 -8
- package/templates/src/components/compositor/tools/NodeOverlay.tsx +224 -0
- package/templates/src/components/compositor/tools/PaneOverlay.tsx +122 -0
- package/templates/src/components/edit/Header.tsx +68 -9
- package/templates/src/components/edit/PanelSwitch.tsx +42 -4
- package/templates/src/components/edit/SettingsPanel.tsx +2 -3
- package/templates/src/components/edit/ToolMode.tsx +1 -31
- package/templates/src/components/edit/pane/AddPanePanel_break.tsx +2 -2
- package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +1 -1
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +193 -659
- package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +15 -82
- package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +95 -45
- package/templates/src/components/edit/pane/ConfigPanePanel.tsx +137 -49
- package/templates/src/components/edit/pane/RestylePaneModal.tsx +1 -1
- package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +375 -0
- package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +1 -23
- package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +327 -0
- package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +267 -0
- package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +371 -0
- package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +201 -76
- package/templates/src/components/edit/pane/steps/CreativeInjectStep.tsx +141 -0
- package/templates/src/components/edit/panels/CreativeImagePanel.tsx +435 -0
- package/templates/src/components/edit/panels/CreativeLinkPanel.tsx +110 -0
- package/templates/src/components/edit/panels/StyleCodeHookPanel.tsx +1 -1
- package/templates/src/components/edit/panels/StyleParentPanel.tsx +118 -126
- package/templates/src/components/edit/panels/StyleParentPanel_add.tsx +3 -2
- package/templates/src/components/edit/panels/StyleParentPanel_deleteLayer.tsx +1 -0
- package/templates/src/components/edit/panels/StyleParentPanel_remove.tsx +3 -1
- package/templates/src/components/edit/panels/StyleParentPanel_update.tsx +3 -1
- package/templates/src/components/edit/panels/StyleWidgetPanel.tsx +1 -1
- package/templates/src/components/edit/state/SaveModal.tsx +19 -787
- package/templates/src/components/edit/state/SaveToLibraryModal.tsx +2 -2
- package/templates/src/components/edit/storyfragment/StoryFragmentPanel_menu.tsx +1 -1
- package/templates/src/components/edit/widgets/BunnyWidget.tsx +5 -5
- package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +1 -1
- package/templates/src/components/edit/widgets/SignupWidget.tsx +1 -1
- package/templates/src/components/fields/ActionBuilderTimeSelector.tsx +1 -1
- package/templates/src/components/fields/ArtpackImage.tsx +11 -3
- package/templates/src/components/fields/BackgroundImage.tsx +8 -0
- package/templates/src/components/fields/BackgroundImageWrapper.tsx +15 -9
- package/templates/src/components/fields/ImageUpload.tsx +6 -0
- package/templates/src/components/form/ActionBuilderField.tsx +15 -5
- package/templates/src/components/form/ActionBuilderSlugSelector.tsx +1 -1
- package/templates/src/components/form/ColorPicker.tsx +1 -1
- package/templates/src/components/form/EnumSelect.tsx +1 -1
- package/templates/src/components/form/NumberInput.tsx +1 -1
- package/templates/src/components/form/StringArrayInput.tsx +1 -1
- package/templates/src/components/form/StringInput.tsx +1 -1
- package/templates/src/components/form/UnsavedChangesBar.tsx +1 -1
- package/templates/src/components/form/advanced/APIConfigSection.tsx +2 -2
- package/templates/src/components/form/advanced/AuthConfigSection.tsx +2 -2
- package/templates/src/components/profile/ProfileCreate.tsx +1 -1
- package/templates/src/components/profile/ProfileEdit.tsx +1 -1
- package/templates/src/components/storykeep/Dashboard_Advanced.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +1 -1
- package/templates/src/components/storykeep/controls/content/ContentSummary.tsx +2 -2
- package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +1 -1
- package/templates/src/components/storykeep/controls/content/ManageContent.tsx +6 -6
- package/templates/src/components/storykeep/controls/content/MenuForm.tsx +1 -1
- package/templates/src/components/storykeep/controls/content/PaneTable.tsx +358 -0
- package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -1
- package/templates/src/constants/prompts.json +18 -10
- package/templates/src/constants.ts +3 -0
- package/templates/src/hooks/usePaneFragments.ts +60 -0
- package/templates/src/lib/session.ts +71 -16
- package/templates/src/pages/[...slug].astro +5 -46
- package/templates/src/pages/api/css.ts +149 -0
- package/templates/src/pages/context/[...contextSlug].astro +1 -0
- package/templates/src/pages/maint.astro +1 -1
- package/templates/src/pages/storykeep/login.astro +2 -2
- package/templates/src/stores/nodes.ts +162 -49
- package/templates/src/stores/orphanAnalysis.ts +6 -30
- package/templates/src/stores/previews.ts +7 -0
- package/templates/src/stores/storykeep.ts +0 -8
- package/templates/src/types/compositorTypes.ts +53 -10
- package/templates/src/utils/compositor/aiGeneration.ts +93 -0
- package/templates/src/utils/compositor/allowInsert.ts +2 -0
- package/templates/src/utils/compositor/htmlAst.ts +704 -0
- package/templates/src/utils/compositor/nodesHelper.ts +281 -102
- package/templates/src/utils/compositor/savePipeline.ts +893 -0
- package/templates/src/utils/etl/index.ts +3 -0
- package/templates/src/utils/etl/transformer.ts +10 -0
- package/templates/src/utils/helpers.ts +101 -0
- package/utils/inject-files.ts +100 -62
- package/templates/icons/text.svg +0 -6
- package/templates/src/components/compositor/NodeWithGuid.tsx +0 -69
- package/templates/src/components/compositor/nodes/GridLayout_eraser.tsx +0 -33
- package/templates/src/components/compositor/nodes/Markdown_eraser.tsx +0 -56
- package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +0 -269
- package/templates/src/components/compositor/nodes/Pane_eraser.tsx +0 -186
- package/templates/src/components/compositor/nodes/Pane_layout.tsx +0 -79
- package/templates/src/components/compositor/nodes/tagElements/NodeA_eraser.tsx +0 -26
- package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_eraser.tsx +0 -61
- package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_insert.tsx +0 -120
- package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_settings.tsx +0 -62
- 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
|
+
}
|