@avocadostudio-ai/migration-sdk 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.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Section spec generator — assembles block-type-agnostic section specifications
3
+ * from extracted content, computed styles, and page outline data.
4
+ *
5
+ * The spec is a faithful representation of the source section, NOT a pre-mapped
6
+ * prop bag. The LLM decides whether to use an existing block or code a new one.
7
+ */
8
+ import type { ExtractedSection, SectionStyles, PageOutline, SectionSpec, FullPageScrape } from "./types.ts";
9
+ /**
10
+ * Build a block-type-agnostic section spec from extracted content, computed styles,
11
+ * and page outline data. The LLM uses this to decide: existing block or custom block.
12
+ */
13
+ export declare function buildSectionSpec(section: ExtractedSection, sectionStyles?: SectionStyles, outlineSection?: PageOutline["sections"][number]): SectionSpec;
14
+ /**
15
+ * Build section specs for all sections of a scraped page.
16
+ *
17
+ * Uses visual sections (from bounding-box gap analysis) as the primary source
18
+ * when available — this is CMS-agnostic and correctly identifies sections on
19
+ * sites that don't use semantic HTML tags. Falls back to regex-based
20
+ * extractSections() when visual data isn't available.
21
+ */
22
+ export declare function buildPageSpecs(scrape: FullPageScrape): SectionSpec[];
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Section spec generator — assembles block-type-agnostic section specifications
3
+ * from extracted content, computed styles, and page outline data.
4
+ *
5
+ * The spec is a faithful representation of the source section, NOT a pre-mapped
6
+ * prop bag. The LLM decides whether to use an existing block or code a new one.
7
+ */
8
+ import { classifySection } from "./section-extractor.js";
9
+ // ── Structure analysis helpers ──
10
+ function childSignature(node) {
11
+ return node.children
12
+ .map((c) => c.tag)
13
+ .join(" + ");
14
+ }
15
+ /** Detect repeated child patterns in a style tree */
16
+ function detectRepeats(root) {
17
+ if (root.children.length < 2)
18
+ return { count: 0 };
19
+ // Group children by their child-tag signature
20
+ const sigMap = new Map();
21
+ for (const child of root.children) {
22
+ const sig = childSignature(child);
23
+ if (!sig)
24
+ continue;
25
+ sigMap.set(sig, (sigMap.get(sig) ?? 0) + 1);
26
+ }
27
+ // Find the most frequent signature with 2+ occurrences
28
+ let bestSig = "";
29
+ let bestCount = 0;
30
+ for (const [sig, count] of sigMap) {
31
+ if (count > bestCount) {
32
+ bestCount = count;
33
+ bestSig = sig;
34
+ }
35
+ }
36
+ // Also check one level deeper — repeated items might be inside a wrapper div
37
+ if (bestCount < 2) {
38
+ for (const wrapper of root.children) {
39
+ if (wrapper.children.length < 2)
40
+ continue;
41
+ const innerSigMap = new Map();
42
+ for (const child of wrapper.children) {
43
+ const sig = childSignature(child);
44
+ if (!sig)
45
+ continue;
46
+ innerSigMap.set(sig, (innerSigMap.get(sig) ?? 0) + 1);
47
+ }
48
+ for (const [sig, count] of innerSigMap) {
49
+ if (count > bestCount) {
50
+ bestCount = count;
51
+ bestSig = sig;
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return bestCount >= 2 ? { count: bestCount, signature: bestSig } : { count: 0 };
57
+ }
58
+ /** Detect repeated patterns from content (headings at same level) when no style tree available */
59
+ function detectRepeatsFromContent(content) {
60
+ // Count headings by level — repeated same-level headings suggest repeated items
61
+ const levelCounts = new Map();
62
+ for (const h of content.headings) {
63
+ levelCounts.set(h.level, (levelCounts.get(h.level) ?? 0) + 1);
64
+ }
65
+ // Find the level with most repetitions (minimum 2)
66
+ let bestLevel = 0;
67
+ let bestCount = 0;
68
+ for (const [level, count] of levelCounts) {
69
+ if (count > bestCount && count >= 2) {
70
+ bestCount = count;
71
+ bestLevel = level;
72
+ }
73
+ }
74
+ if (bestCount >= 2) {
75
+ // Check if there are also lists that correlate (e.g., pricing items with feature lists)
76
+ const hasLists = content.lists.length > 0;
77
+ const signature = hasLists ? `h${bestLevel} + list` : `h${bestLevel}`;
78
+ return { count: bestCount, signature };
79
+ }
80
+ return { count: 0 };
81
+ }
82
+ /** Infer layout pattern from computed styles */
83
+ function inferPattern(root, repeats) {
84
+ const s = root.styles;
85
+ const display = s.display ?? "";
86
+ const flexDir = s.flexDirection ?? "";
87
+ const gridCols = s.gridTemplateColumns ?? "";
88
+ // Grid layout
89
+ if (display === "grid" && gridCols) {
90
+ const colCount = gridCols.split(/\s+/).filter((v) => v && v !== "0px").length;
91
+ if (repeats.count > 0) {
92
+ return `${colCount}-column grid of ${repeats.count} items`;
93
+ }
94
+ return `${colCount}-column grid`;
95
+ }
96
+ // Flex row
97
+ if (display === "flex" && flexDir === "row") {
98
+ if (repeats.count > 0)
99
+ return `horizontal row of ${repeats.count} items`;
100
+ return "side-by-side layout";
101
+ }
102
+ // Flex column (stacked)
103
+ if (display === "flex" && (flexDir === "column" || !flexDir)) {
104
+ if (repeats.count > 0)
105
+ return `stacked list of ${repeats.count} items`;
106
+ return "vertically stacked";
107
+ }
108
+ // Fallback
109
+ if (repeats.count > 0)
110
+ return `${repeats.count} repeated items`;
111
+ return "single section";
112
+ }
113
+ /** Infer interaction model from section content and DOM structure */
114
+ function inferInteractionModel(section, root, repeats) {
115
+ const html = section.rawHtml.toLowerCase();
116
+ if (html.includes("<details") || html.includes("accordion"))
117
+ return "accordion";
118
+ if (html.includes("role=\"tablist\"") || html.includes("tab-content") || html.includes("tabpanel"))
119
+ return "tabs";
120
+ if (html.includes("carousel") || html.includes("swiper") || html.includes("slider") || html.includes("slick"))
121
+ return "carousel";
122
+ if (html.includes("scroll-snap") || html.includes("scroll-driven"))
123
+ return "scroll-driven";
124
+ // Horizontal scroll container with repeated items is likely a carousel
125
+ if (root && repeats.count >= 3) {
126
+ const overflow = root.styles.overflow ?? root.styles.overflowX ?? "";
127
+ if ((overflow === "hidden" || overflow === "scroll") && root.styles.display === "flex" && root.styles.flexDirection === "row") {
128
+ return "carousel";
129
+ }
130
+ }
131
+ return "static";
132
+ }
133
+ function countElements(node) {
134
+ let count = 1;
135
+ for (const child of node.children) {
136
+ count += countElements(child);
137
+ }
138
+ return count;
139
+ }
140
+ // ── Style extraction helpers ──
141
+ function findFirst(node, predicate) {
142
+ if (predicate(node))
143
+ return node;
144
+ for (const child of node.children) {
145
+ const found = findFirst(child, predicate);
146
+ if (found)
147
+ return found;
148
+ }
149
+ return undefined;
150
+ }
151
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
152
+ /** Extract role-based style summaries from the computed style tree */
153
+ function extractRoleStyles(root, repeats) {
154
+ const styles = {
155
+ container: root.styles,
156
+ };
157
+ // Heading — first h1-h3
158
+ const heading = findFirst(root, (n) => HEADING_TAGS.has(n.tag));
159
+ if (heading)
160
+ styles.heading = heading.styles;
161
+ // Body text — first <p>
162
+ const bodyP = findFirst(root, (n) => n.tag === "p" && !!n.text && n.text.length > 10);
163
+ if (bodyP)
164
+ styles.bodyText = bodyP.styles;
165
+ // Repeated item — styles of first repeated child
166
+ if (repeats.count > 0 && repeats.signature) {
167
+ const sig = repeats.signature;
168
+ // Find first child whose signature matches
169
+ for (const child of root.children) {
170
+ if (childSignature(child) === sig) {
171
+ styles.repeatedItem = child.styles;
172
+ break;
173
+ }
174
+ }
175
+ // Try one level deeper if not found
176
+ if (!styles.repeatedItem) {
177
+ for (const wrapper of root.children) {
178
+ for (const child of wrapper.children) {
179
+ if (childSignature(child) === sig) {
180
+ styles.repeatedItem = child.styles;
181
+ break;
182
+ }
183
+ }
184
+ if (styles.repeatedItem)
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ // CTA — first <a> or <button> with a background color
190
+ const cta = findFirst(root, (n) => {
191
+ if (n.tag !== "a" && n.tag !== "button")
192
+ return false;
193
+ const bg = n.styles.backgroundColor ?? "";
194
+ return !!bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent";
195
+ });
196
+ if (cta)
197
+ styles.cta = cta.styles;
198
+ return styles;
199
+ }
200
+ /** Build designNotes from extracted styles */
201
+ function buildDesignNotes(styles) {
202
+ const container = styles.container;
203
+ const heading = styles.heading ?? {};
204
+ return {
205
+ backgroundColor: container.backgroundColor ?? container.background ?? "",
206
+ textColor: heading.color ?? styles.bodyText?.color ?? "",
207
+ headingFont: heading.fontFamily ?? "",
208
+ headingSize: heading.fontSize ?? "",
209
+ layout: container.display ?? "block",
210
+ hasGradient: !!(container.background ?? container.backgroundImage ?? "").includes("gradient"),
211
+ hasShadow: !!(container.boxShadow ?? ""),
212
+ borderRadius: container.borderRadius ?? "",
213
+ };
214
+ }
215
+ // ── Public API ──
216
+ /**
217
+ * Build a block-type-agnostic section spec from extracted content, computed styles,
218
+ * and page outline data. The LLM uses this to decide: existing block or custom block.
219
+ */
220
+ export function buildSectionSpec(section, sectionStyles, outlineSection) {
221
+ const root = sectionStyles?.root;
222
+ // Content — carry forward from section, enriching images with background flag
223
+ const images = section.content.images.map((img) => ({
224
+ src: img.src,
225
+ alt: img.alt,
226
+ isBackground: false,
227
+ }));
228
+ // Inject background images from computed styles — root level and per-item children.
229
+ // Elementor event/service cards use CSS background-image on each card div, not <img> tags.
230
+ if (root) {
231
+ const bgImg = root.styles.backgroundImage ?? root.styles.background ?? "";
232
+ const urlMatch = bgImg.match(/url\(["']?([^"')]+)["']?\)/);
233
+ if (urlMatch?.[1] && !urlMatch[1].startsWith("data:")) {
234
+ images.push({ src: urlMatch[1], alt: "", isBackground: true });
235
+ }
236
+ // Walk direct children (and one level deeper for wrapper divs) to collect
237
+ // per-item background images from card/event grids.
238
+ const existingSrcs = new Set(images.map((i) => i.src));
239
+ const collectChildBgImages = (nodes) => {
240
+ for (const node of nodes) {
241
+ const childBg = node.styles.backgroundImage ?? node.styles.background ?? "";
242
+ const childMatch = childBg.match(/url\(["']?([^"')]+)["']?\)/);
243
+ if (childMatch?.[1] && !childMatch[1].startsWith("data:") && !existingSrcs.has(childMatch[1])) {
244
+ images.push({ src: childMatch[1], alt: "", isBackground: true });
245
+ existingSrcs.add(childMatch[1]);
246
+ }
247
+ }
248
+ };
249
+ collectChildBgImages(root.children);
250
+ for (const child of root.children) {
251
+ collectChildBgImages(child.children);
252
+ }
253
+ }
254
+ const content = {
255
+ headings: section.content.headings,
256
+ paragraphs: section.content.paragraphs,
257
+ images,
258
+ links: section.content.links,
259
+ lists: section.content.lists,
260
+ };
261
+ // Structure analysis
262
+ let repeats = root ? detectRepeats(root) : { count: 0 };
263
+ // Content-based repeat detection fallback — when no style tree,
264
+ // detect repeated patterns from headings at the same level
265
+ if (repeats.count < 2 && !root && content.headings.length >= 2) {
266
+ repeats = detectRepeatsFromContent(content);
267
+ }
268
+ const pattern = root ? inferPattern(root, repeats) : (repeats.count >= 2 ? `${repeats.count} repeated items` : "unknown");
269
+ const interactionModel = inferInteractionModel(section, root, repeats);
270
+ const elementCount = root ? countElements(root) : 0;
271
+ const structure = {
272
+ pattern,
273
+ repeatCount: repeats.count,
274
+ repeatSignature: repeats.signature,
275
+ elementCount,
276
+ interactionModel,
277
+ };
278
+ // Styles — role-based summaries from computed style tree
279
+ const roleStyles = root
280
+ ? extractRoleStyles(root, repeats)
281
+ : { container: {} };
282
+ // Design notes
283
+ const designNotes = buildDesignNotes(roleStyles);
284
+ // Heuristic suggestion — carried from section extractor, not authoritative
285
+ let suggestedBlockType = section.suggestedBlockType;
286
+ let suggestedConfidence = suggestedBlockType ? 0.5 : 0;
287
+ // Structural override: repeated items with headings should be CardGrid/FeatureGrid, not RichText
288
+ if (repeats.count >= 2 && content.headings.length >= 2) {
289
+ if (!suggestedBlockType || suggestedBlockType === "RichText") {
290
+ const hasImages = content.images.length > 0 || content.links.length > 0;
291
+ suggestedBlockType = hasImages ? "CardGrid" : "FeatureGrid";
292
+ suggestedConfidence = Math.max(suggestedConfidence, 0.6);
293
+ }
294
+ }
295
+ // Structural override: h1 + image → Hero (overrides RichText since h1+image is hero-like)
296
+ if (content.headings.some(h => h.level === 1) && content.images.length > 0 &&
297
+ (!suggestedBlockType || suggestedBlockType === "RichText")) {
298
+ suggestedBlockType = "Hero";
299
+ suggestedConfidence = Math.max(suggestedConfidence, 0.7);
300
+ }
301
+ // Boost confidence if outline data corroborates
302
+ if (outlineSection && suggestedBlockType) {
303
+ if (outlineSection.repeatGroups && outlineSection.repeatGroups.length > 0) {
304
+ suggestedConfidence = Math.min(suggestedConfidence + 0.15, 1);
305
+ }
306
+ if (outlineSection.type !== "unknown") {
307
+ suggestedConfidence = Math.min(suggestedConfidence + 0.1, 1);
308
+ }
309
+ }
310
+ // Boost if computed styles confirm the pattern
311
+ if (root && suggestedBlockType) {
312
+ if (repeats.count >= 3)
313
+ suggestedConfidence = Math.min(suggestedConfidence + 0.1, 1);
314
+ if (Object.keys(roleStyles.container).length > 5)
315
+ suggestedConfidence = Math.min(suggestedConfidence + 0.05, 1);
316
+ }
317
+ return {
318
+ sectionIndex: section.index,
319
+ content,
320
+ structure,
321
+ styles: roleStyles,
322
+ designNotes,
323
+ suggestedBlockType,
324
+ suggestedConfidence,
325
+ };
326
+ }
327
+ /**
328
+ * Build section specs for all sections of a scraped page.
329
+ *
330
+ * Uses visual sections (from bounding-box gap analysis) as the primary source
331
+ * when available — this is CMS-agnostic and correctly identifies sections on
332
+ * sites that don't use semantic HTML tags. Falls back to regex-based
333
+ * extractSections() when visual data isn't available.
334
+ */
335
+ export function buildPageSpecs(scrape) {
336
+ const stylesMap = new Map();
337
+ if (scrape.sectionStyles) {
338
+ for (const ss of scrape.sectionStyles) {
339
+ stylesMap.set(ss.sectionIndex, ss);
340
+ }
341
+ }
342
+ // Collect ALL images from all regex-extracted sections for redistribution by Y-position
343
+ const allExtractedImages = scrape.sections.flatMap((s) => s.content.images.map((img) => ({ ...img, sectionIndex: s.index })));
344
+ // Build fallback content map for sections where findElementForRange failed
345
+ const fallbackMap = new Map();
346
+ if (scrape.sectionFallbackContent) {
347
+ for (const fb of scrape.sectionFallbackContent) {
348
+ fallbackMap.set(fb.sectionIndex, fb);
349
+ }
350
+ }
351
+ // If we have visual sections AND computed styles from them, use those as primary
352
+ if (scrape.visualSections && scrape.visualSections.length > 0 && scrape.sectionStyles && scrape.sectionStyles.length > 0) {
353
+ return scrape.visualSections.map((vs, i) => {
354
+ const styles = stylesMap.get(i);
355
+ const styleHeadings = styles?.root ? findHeadingTexts(styles.root) : [];
356
+ // Match by heading text first, then by content overlap
357
+ const matchedSection = scrape.sections.find((s) => s.content.headings.some((h) => styleHeadings.includes(h.text)));
358
+ // Find matching outline section
359
+ const outlineSection = scrape.outline.sections.find((os) => os.heading && styleHeadings.includes(os.heading)) ?? scrape.outline.sections[i];
360
+ // Build content: start with matched section's content (best images from regex parser),
361
+ // then enrich with style tree data and page-level image data for anything missing
362
+ const styleContent = extractContentFromStyleTree(styles?.root, scrape.embeds, vs, scrape.videos);
363
+ const baseContent = matchedSection?.content ?? { headings: [], paragraphs: [], images: [], links: [], lists: [] };
364
+ // Fallback content from Y-range text scan (for sections where findElementForRange failed)
365
+ const fallback = fallbackMap.get(i);
366
+ // Collect page images that fall within this visual section's Y range
367
+ const sectionPageImages = [];
368
+ if (scrape.pageImages) {
369
+ for (const img of scrape.pageImages) {
370
+ if (img.y >= vs.y && img.y < vs.y + vs.height) {
371
+ sectionPageImages.push({ src: img.src, alt: img.alt, isLazy: false });
372
+ }
373
+ }
374
+ }
375
+ // Merge content from all sources: regex-extracted > style tree > fallback Y-scan
376
+ const mergedContent = {
377
+ headings: pickFirst(baseContent.headings, styleContent.headings, fallback?.headings ?? []),
378
+ paragraphs: pickFirst(baseContent.paragraphs, styleContent.paragraphs, fallback?.paragraphs ?? []),
379
+ images: mergeImages(mergeImages(baseContent.images, sectionPageImages), styleContent.images),
380
+ links: pickFirst(baseContent.links, styleContent.links, fallback?.links ?? []),
381
+ lists: pickFirst(baseContent.lists, styleContent.lists, fallback?.lists ?? []),
382
+ };
383
+ // Classify if no type inherited from matched regex section
384
+ let suggestedBlockType = matchedSection?.suggestedBlockType;
385
+ let classHints = matchedSection?.classHints ?? [];
386
+ if (!suggestedBlockType && matchedSection?.rawHtml) {
387
+ const classified = classifySection(`<${matchedSection.tag}>`, matchedSection.rawHtml, { headings: mergedContent.headings });
388
+ suggestedBlockType = classified.suggestedBlockType;
389
+ if (classified.classHints.length > 0)
390
+ classHints = classified.classHints;
391
+ }
392
+ // Also classify from style tree content if still unclassified
393
+ if (!suggestedBlockType && styles?.root) {
394
+ const treeHtml = reconstructHtmlFromStyleTree(styles.root);
395
+ if (treeHtml.length > 20) {
396
+ const classified = classifySection("<div>", treeHtml, { headings: mergedContent.headings });
397
+ suggestedBlockType = classified.suggestedBlockType;
398
+ if (classified.classHints.length > 0)
399
+ classHints = classified.classHints;
400
+ }
401
+ }
402
+ const section = {
403
+ index: i,
404
+ tag: matchedSection?.tag ?? "div",
405
+ classHints,
406
+ suggestedBlockType,
407
+ content: mergedContent,
408
+ rawHtml: matchedSection?.rawHtml ?? "",
409
+ };
410
+ const spec = buildSectionSpec(section, styles, outlineSection);
411
+ // Attach interaction states captured during interaction sweep
412
+ if (scrape.interactionStates) {
413
+ const sectionInteractions = scrape.interactionStates
414
+ .filter(is => is.sectionY >= vs.y && is.sectionY < vs.y + vs.height)
415
+ .flatMap(is => is.states);
416
+ if (sectionInteractions.length > 0) {
417
+ spec.interactionStates = sectionInteractions;
418
+ }
419
+ }
420
+ return spec;
421
+ });
422
+ }
423
+ // Fallback: use regex-based sections
424
+ return scrape.sections.map((section, i) => {
425
+ const styles = stylesMap.get(i);
426
+ const outlineSection = scrape.outline.sections[i];
427
+ return buildSectionSpec(section, styles, outlineSection);
428
+ });
429
+ }
430
+ /** Reconstruct approximate HTML from a computed style tree for classification heuristics. */
431
+ function reconstructHtmlFromStyleTree(node) {
432
+ const parts = [];
433
+ if (node.text) {
434
+ const tag = HEADING_TAGS.has(node.tag) ? node.tag : "p";
435
+ parts.push(`<${tag}>${node.text}</${tag}>`);
436
+ }
437
+ if (node.image) {
438
+ parts.push(`<img src="${node.image}" alt="">`);
439
+ }
440
+ for (const child of node.children) {
441
+ parts.push(reconstructHtmlFromStyleTree(child));
442
+ }
443
+ return parts.join("");
444
+ }
445
+ /** Return the first non-empty array from the candidates. */
446
+ function pickFirst(...candidates) {
447
+ for (const c of candidates) {
448
+ if (c.length > 0)
449
+ return c;
450
+ }
451
+ return [];
452
+ }
453
+ /** Merge images from two sources, deduplicating by src. */
454
+ function mergeImages(a, b) {
455
+ const seen = new Set();
456
+ const result = [];
457
+ for (const img of [...a, ...b]) {
458
+ if (!img.src || seen.has(img.src))
459
+ continue;
460
+ seen.add(img.src);
461
+ result.push({ src: img.src, alt: img.alt, isLazy: img.isLazy ?? false });
462
+ }
463
+ return result;
464
+ }
465
+ /** Extract heading texts from a computed style tree for section matching. */
466
+ function findHeadingTexts(node) {
467
+ const texts = [];
468
+ if (HEADING_TAGS.has(node.tag) && node.text)
469
+ texts.push(node.text);
470
+ for (const child of node.children) {
471
+ texts.push(...findHeadingTexts(child));
472
+ }
473
+ return texts;
474
+ }
475
+ /** Build content from a computed style tree when no regex-extracted section matches. */
476
+ function extractContentFromStyleTree(root, embeds, vs, videos) {
477
+ const headings = [];
478
+ const paragraphs = [];
479
+ const images = [];
480
+ const links = [];
481
+ if (root) {
482
+ walkForContent(root, headings, paragraphs, images);
483
+ }
484
+ // Add embeds that fall within this visual section's Y range
485
+ if (embeds) {
486
+ for (const embed of embeds) {
487
+ if (embed.y >= vs.y && embed.y < vs.y + vs.height) {
488
+ links.push({ href: embed.src, text: `[${embed.type} embed]` });
489
+ }
490
+ }
491
+ }
492
+ // Add <video> elements that fall within this visual section's Y range
493
+ if (videos) {
494
+ for (const video of videos) {
495
+ if (video.y >= vs.y && video.y < vs.y + vs.height) {
496
+ links.push({ href: video.src, text: `[video${video.autoplay ? " autoplay" : ""}${video.loop ? " loop" : ""}]` });
497
+ if (video.poster) {
498
+ images.push({ src: video.poster, alt: "Video poster", isLazy: false });
499
+ }
500
+ }
501
+ }
502
+ }
503
+ return { headings, paragraphs, images, links, lists: [] };
504
+ }
505
+ /** Recursively extract content from a computed style tree. */
506
+ function walkForContent(node, headings, paragraphs, images) {
507
+ if (HEADING_TAGS.has(node.tag) && node.text) {
508
+ const level = parseInt(node.tag[1]);
509
+ headings.push({ level, text: node.text });
510
+ }
511
+ else if (node.tag === "p" && node.text && node.text.length > 10) {
512
+ paragraphs.push(node.text);
513
+ }
514
+ if (node.image) {
515
+ images.push({ src: node.image.src, alt: node.image.alt, isLazy: false });
516
+ }
517
+ // Check for background images
518
+ const bgImg = node.styles.backgroundImage ?? "";
519
+ const bgMatch = bgImg.match(/url\(["']?([^"')]+)["']?\)/);
520
+ if (bgMatch?.[1] && !bgMatch[1].startsWith("data:")) {
521
+ images.push({ src: bgMatch[1], alt: "", isLazy: false });
522
+ }
523
+ for (const child of node.children) {
524
+ walkForContent(child, headings, paragraphs, images);
525
+ }
526
+ }