cf-pagetree-parser 1.0.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/src/index.js ADDED
@@ -0,0 +1,708 @@
1
+ /**
2
+ * ============================================================================
3
+ * FUNNELWIND PAGETREE PARSER
4
+ * ============================================================================
5
+ *
6
+ * Converts rendered FunnelWind web components to ClickFunnels pagetree JSON.
7
+ *
8
+ * USAGE:
9
+ * // After FunnelWind renders the page
10
+ * import { parsePageTree } from './pagetree-parser/index.js';
11
+ *
12
+ * // Parse the entire page
13
+ * const pageTree = parsePageTree();
14
+ *
15
+ * // Or parse from a specific container
16
+ * const pageTree = parsePageTree(document.querySelector('[data-type="ContentNode"]'));
17
+ *
18
+ * // Export as JSON
19
+ * const json = JSON.stringify(pageTree, null, 2);
20
+ *
21
+ * ============================================================================
22
+ */
23
+
24
+ import { getDataType, generateId, normalizeColor } from "./utils.js";
25
+ import { normalizeFontFamily } from "./styles.js";
26
+
27
+ import {
28
+ parseContentNode,
29
+ parseSectionContainer,
30
+ parseRowContainer,
31
+ parseColContainer,
32
+ parseColInner,
33
+ parseFlexContainer,
34
+ parseHeadline,
35
+ parseSubHeadline,
36
+ parseParagraph,
37
+ parseButton,
38
+ parseImage,
39
+ parseIcon,
40
+ parseVideo,
41
+ parseDivider,
42
+ parseInput,
43
+ parseTextArea,
44
+ parseSelectBox,
45
+ parseCheckbox,
46
+ parseBulletList,
47
+ parseProgressBar,
48
+ parseVideoPopup,
49
+ parseCountdown,
50
+ parseCheckoutPlaceholder,
51
+ parseOrderSummaryPlaceholder,
52
+ parseConfirmationPlaceholder,
53
+ parseModalContainer,
54
+ } from "./parsers/index.js";
55
+
56
+ /**
57
+ * Get typescale sizes calculated from baseSize and scaleRatio.
58
+ * Matches StyleguideEditor.tsx and cf-elements.js getTypescale() for consistency.
59
+ * Returns element-specific scales because headlines, subheadlines, and paragraphs
60
+ * have different sizes at the same preset (e.g., "s" for headline = 20px, "s" for paragraph = 13px).
61
+ *
62
+ * @param {Object} typography - Typography settings with baseSize and scaleRatio
63
+ * @returns {Object|null} - Map of element types to their size preset maps
64
+ */
65
+ function getTypescale(typography) {
66
+ if (!typography) return null;
67
+
68
+ const { baseSize = 16, scaleRatio = 1.25 } = typography;
69
+ const r = scaleRatio;
70
+ const b = baseSize;
71
+
72
+ // Build the base scale points (negative = smaller, positive = larger)
73
+ const scale = {
74
+ n3: Math.round(b / Math.pow(r, 3)), // ~8
75
+ n2: Math.round(b / Math.pow(r, 2)), // ~10
76
+ n1: Math.round(b / r), // ~13
77
+ base: b, // 16
78
+ p1: Math.round(b * r), // ~20
79
+ p2: Math.round(b * Math.pow(r, 2)), // ~25
80
+ p3: Math.round(b * Math.pow(r, 3)), // ~31
81
+ p4: Math.round(b * Math.pow(r, 4)), // ~39
82
+ p5: Math.round(b * Math.pow(r, 5)), // ~49
83
+ p6: Math.round(b * Math.pow(r, 6)), // ~61
84
+ p7: Math.round(b * Math.pow(r, 7)), // ~76
85
+ p8: Math.round(b * Math.pow(r, 8)), // ~95
86
+ };
87
+
88
+ // Return element-specific scales (each element type maps presets to different scale points)
89
+ return {
90
+ headline: {
91
+ "5xl": scale.p8,
92
+ "4xl": scale.p7,
93
+ "3xl": scale.p6,
94
+ "2xl": scale.p5,
95
+ xl: scale.p4,
96
+ l: scale.p3,
97
+ lg: scale.p3,
98
+ m: scale.p2,
99
+ md: scale.p2,
100
+ s: scale.p1,
101
+ sm: scale.p1,
102
+ xs: scale.base,
103
+ },
104
+ subheadline: {
105
+ "5xl": scale.p7,
106
+ "4xl": scale.p6,
107
+ "3xl": scale.p5,
108
+ "2xl": scale.p4,
109
+ xl: scale.p3,
110
+ l: scale.p2,
111
+ lg: scale.p2,
112
+ m: scale.p1,
113
+ md: scale.p1,
114
+ s: scale.base,
115
+ sm: scale.base,
116
+ xs: scale.n1,
117
+ },
118
+ paragraph: {
119
+ "5xl": scale.p6,
120
+ "4xl": scale.p5,
121
+ "3xl": scale.p4,
122
+ "2xl": scale.p3,
123
+ xl: scale.p2,
124
+ l: scale.p1,
125
+ lg: scale.p1,
126
+ m: scale.base,
127
+ md: scale.base,
128
+ s: scale.n1,
129
+ sm: scale.n1,
130
+ xs: scale.n2,
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Apply styleguide fonts and colors as data attributes before parsing.
137
+ * This ensures the parser captures styleguide-applied values.
138
+ *
139
+ * @param {Document|HTMLElement} root - The root element or document to process
140
+ * @param {Object} styleguideData - Optional styleguide data (will try to read from embedded JSON if not provided)
141
+ */
142
+ function applyStyleguideDataAttributes(root, styleguideData = null) {
143
+ // Try to get styleguide from embedded JSON if not provided
144
+ if (!styleguideData) {
145
+ const scriptEl = root.querySelector
146
+ ? root.querySelector("#cf-styleguide-data")
147
+ : root.getElementById
148
+ ? root.getElementById("cf-styleguide-data")
149
+ : null;
150
+ if (scriptEl) {
151
+ try {
152
+ styleguideData = JSON.parse(scriptEl.textContent);
153
+ } catch (e) {
154
+ console.warn("PageTree Parser: Failed to parse styleguide data:", e);
155
+ return;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (!styleguideData) return;
161
+
162
+ const { typography, paintThemes, colors } = styleguideData;
163
+
164
+ // Helper to get color hex by ID
165
+ const getColorHex = (colorId) => {
166
+ if (!colors) return "#000000";
167
+ const color = colors.find((c) => c.id === colorId);
168
+ return color ? color.hex : "#000000";
169
+ };
170
+
171
+ // Apply typography fonts to elements without explicit fonts
172
+ if (typography) {
173
+ const { headlineFont, subheadlineFont, contentFont } = typography;
174
+
175
+ if (headlineFont) {
176
+ root
177
+ .querySelectorAll('[data-type="Headline/V1"]:not([data-font])')
178
+ .forEach((el) => {
179
+ el.setAttribute("data-font", headlineFont);
180
+ });
181
+ }
182
+
183
+ if (subheadlineFont) {
184
+ root
185
+ .querySelectorAll('[data-type="SubHeadline/V1"]:not([data-font])')
186
+ .forEach((el) => {
187
+ el.setAttribute("data-font", subheadlineFont);
188
+ });
189
+ }
190
+
191
+ if (contentFont) {
192
+ root
193
+ .querySelectorAll('[data-type="Paragraph/V1"]:not([data-font])')
194
+ .forEach((el) => {
195
+ el.setAttribute("data-font", contentFont);
196
+ });
197
+ }
198
+ }
199
+
200
+ // Helper to check if element's closest paint-themed ancestor is the given container
201
+ // This prevents applying colors to elements inside nested non-paint containers
202
+ const isDirectPaintDescendant = (el, paintContainer) => {
203
+ // Find the closest ancestor with data-paint-colors attribute
204
+ const closestPaint = el.closest("[data-paint-colors]");
205
+ // Element should only get colors if its closest paint ancestor is this container
206
+ return closestPaint === paintContainer;
207
+ };
208
+
209
+ // Apply paint theme colors - OVERRIDE existing (paint themes take precedence)
210
+ // Only apply to elements that are direct descendants (no intervening non-paint containers)
211
+ if (paintThemes?.length) {
212
+ paintThemes.forEach((theme) => {
213
+ const containers = root.querySelectorAll(
214
+ `[data-paint-colors="${theme.id}"]`
215
+ );
216
+ containers.forEach((container) => {
217
+ const headlineColor = getColorHex(theme.headlineColorId);
218
+ const subheadlineColor = getColorHex(theme.subheadlineColorId);
219
+ const contentColor = getColorHex(theme.contentColorId);
220
+ const iconColor = getColorHex(theme.iconColorId);
221
+ const linkColor = theme.linkColorId
222
+ ? getColorHex(theme.linkColorId)
223
+ : null;
224
+
225
+ // Apply headline color (only to direct paint descendants)
226
+ container
227
+ .querySelectorAll('[data-type="Headline/V1"]')
228
+ .forEach((el) => {
229
+ if (isDirectPaintDescendant(el, container)) {
230
+ if (!el.hasAttribute("data-color-explicit")) {
231
+ el.setAttribute("data-color", headlineColor);
232
+ }
233
+ if (linkColor) el.setAttribute("data-link-color", linkColor);
234
+ }
235
+ });
236
+
237
+ // Apply subheadline color (only to direct paint descendants)
238
+ container
239
+ .querySelectorAll('[data-type="SubHeadline/V1"]')
240
+ .forEach((el) => {
241
+ if (isDirectPaintDescendant(el, container)) {
242
+ if (!el.hasAttribute("data-color-explicit")) {
243
+ el.setAttribute("data-color", subheadlineColor);
244
+ }
245
+ if (linkColor) el.setAttribute("data-link-color", linkColor);
246
+ }
247
+ });
248
+
249
+ // Apply content/paragraph color (only to direct paint descendants)
250
+ container
251
+ .querySelectorAll('[data-type="Paragraph/V1"]')
252
+ .forEach((el) => {
253
+ if (isDirectPaintDescendant(el, container)) {
254
+ if (!el.hasAttribute("data-color-explicit")) {
255
+ el.setAttribute("data-color", contentColor);
256
+ }
257
+ if (linkColor) el.setAttribute("data-link-color", linkColor);
258
+ }
259
+ });
260
+
261
+ // Apply icon color (only to direct paint descendants)
262
+ container.querySelectorAll('[data-type="Icon/V1"]').forEach((el) => {
263
+ if (isDirectPaintDescendant(el, container)) {
264
+ if (!el.hasAttribute("data-color-explicit")) {
265
+ el.setAttribute("data-color", iconColor);
266
+ }
267
+ }
268
+ });
269
+
270
+ // Apply colors to bullet lists (only to direct paint descendants)
271
+ container
272
+ .querySelectorAll('[data-type="BulletList/V1"]')
273
+ .forEach((el) => {
274
+ if (isDirectPaintDescendant(el, container)) {
275
+ if (!el.hasAttribute("data-text-color-explicit")) {
276
+ el.setAttribute("data-text-color", contentColor);
277
+ }
278
+ if (!el.hasAttribute("data-icon-color-explicit")) {
279
+ el.setAttribute("data-icon-color", iconColor);
280
+ }
281
+ if (linkColor) el.setAttribute("data-link-color", linkColor);
282
+ }
283
+ });
284
+ });
285
+ });
286
+ }
287
+
288
+ // Resolve size presets (xl, l, m, s) to pixel values for text elements
289
+ // This allows the parser to capture correct font sizes even when using presets
290
+ if (typography) {
291
+ const typescale = getTypescale(typography);
292
+
293
+ if (typescale) {
294
+ // Map data-type to element scale key
295
+ const elementTypeMap = {
296
+ "Headline/V1": "headline",
297
+ "SubHeadline/V1": "subheadline",
298
+ "Paragraph/V1": "paragraph",
299
+ "BulletList/V1": "paragraph", // Bullet lists use paragraph scale
300
+ };
301
+
302
+ // Find all text elements with a size attribute
303
+ const textElements = root.querySelectorAll(
304
+ '[data-type="Headline/V1"][data-size], ' +
305
+ '[data-type="SubHeadline/V1"][data-size], ' +
306
+ '[data-type="Paragraph/V1"][data-size], ' +
307
+ '[data-type="BulletList/V1"][data-size]'
308
+ );
309
+
310
+ textElements.forEach((el) => {
311
+ const sizeAttr = el.getAttribute("data-size");
312
+ const dataType = el.getAttribute("data-type");
313
+ const elementKey = elementTypeMap[dataType] || "headline";
314
+ const elementScale = typescale[elementKey];
315
+
316
+ // Check if it's a preset (not already a px value)
317
+ if (sizeAttr && elementScale && elementScale[sizeAttr] !== undefined) {
318
+ // Set the resolved pixel value as a separate attribute
319
+ el.setAttribute("data-size-resolved", `${elementScale[sizeAttr]}px`);
320
+ }
321
+ });
322
+ }
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Map of data-type to parser function
328
+ */
329
+ const PARSER_MAP = {
330
+ ContentNode: parseContentNode,
331
+ "SectionContainer/V1": parseSectionContainer,
332
+ "RowContainer/V1": parseRowContainer,
333
+ "ColContainer/V1": parseColContainer,
334
+ "ColInner/V1": parseColInner,
335
+ "FlexContainer/V1": parseFlexContainer,
336
+ "Headline/V1": parseHeadline,
337
+ "SubHeadline/V1": parseSubHeadline,
338
+ "Paragraph/V1": parseParagraph,
339
+ "Button/V1": parseButton,
340
+ "Image/V2": parseImage,
341
+ "Icon/V1": parseIcon,
342
+ "Video/V1": parseVideo,
343
+ "Divider/V1": parseDivider,
344
+ "Input/V1": parseInput,
345
+ "TextArea/V1": parseTextArea,
346
+ "SelectBox/V1": parseSelectBox,
347
+ "Checkbox/V1": parseCheckbox,
348
+ "BulletList/V1": parseBulletList,
349
+ "ProgressBar/V1": parseProgressBar,
350
+ "VideoPopup/V1": parseVideoPopup,
351
+ "Countdown/V1": parseCountdown,
352
+ // Placeholders (output actual ClickFunnels element types)
353
+ "CheckoutPlaceholder": parseCheckoutPlaceholder,
354
+ "OrderSummaryPlaceholder": parseOrderSummaryPlaceholder,
355
+ "ConfirmationPlaceholder": parseConfirmationPlaceholder,
356
+ // Popup
357
+ "ModalContainer/V1": parseModalContainer,
358
+ };
359
+
360
+ /**
361
+ * Create parseElement function with styleguide data in closure
362
+ * Also tracks element-id to internal-id mappings for scroll/show-hide resolution
363
+ */
364
+ function createParseElement(styleguideData, elementIdMap) {
365
+ function parseElement(element, parentId, index) {
366
+ const dataType = getDataType(element);
367
+
368
+ // Skip elements without data-type (like col-inner wrappers)
369
+ if (!dataType) {
370
+ return null;
371
+ }
372
+
373
+ const parser = PARSER_MAP[dataType];
374
+ if (!parser) {
375
+ console.warn(`No parser found for data-type: ${dataType}`);
376
+ return null;
377
+ }
378
+
379
+ // Container elements need the parseChildren callback
380
+ const containerTypes = [
381
+ "ContentNode",
382
+ "SectionContainer/V1",
383
+ "RowContainer/V1",
384
+ "ColContainer/V1",
385
+ "FlexContainer/V1",
386
+ ];
387
+
388
+ let node;
389
+ if (containerTypes.includes(dataType)) {
390
+ node = parser(element, parentId, index, parseElement);
391
+ } else if (dataType === "Button/V1") {
392
+ // Button needs styleguide data to look up button styles
393
+ node = parser(element, parentId, index, styleguideData);
394
+ } else {
395
+ node = parser(element, parentId, index);
396
+ }
397
+
398
+ // Track element-id to internal-id mapping for scroll/show-hide resolution
399
+ if (node && node.attrs && node.attrs.id && node.id) {
400
+ elementIdMap[node.attrs.id] = node.id;
401
+ }
402
+
403
+ return node;
404
+ }
405
+
406
+ return parseElement;
407
+ }
408
+
409
+ /**
410
+ * Resolve element-id references in button scroll-target and show-ids/hide-ids
411
+ * to use internal pagetree IDs with id- prefix
412
+ */
413
+ function resolveButtonReferences(node, elementIdMap) {
414
+ if (!node) return;
415
+
416
+ // Process buttons
417
+ if (node.type === "Button/V1" && node.params) {
418
+ // Resolve scroll href: #scroll-elementId → #scroll-id-internalId
419
+ if (node.params.href && node.params.href.startsWith("#scroll-")) {
420
+ const elementId = node.params.href.replace("#scroll-", "");
421
+ const internalId = elementIdMap[elementId];
422
+ if (internalId) {
423
+ node.params.href = `#scroll-id-${internalId}`;
424
+ }
425
+ }
426
+
427
+ // Resolve showIds: elementId1,elementId2 → id-internalId1,id-internalId2
428
+ if (node.params.showIds) {
429
+ const ids = node.params.showIds.split(",").map((id) => id.trim());
430
+ const resolvedIds = ids.map((elementId) => {
431
+ const internalId = elementIdMap[elementId];
432
+ return internalId ? `id-${internalId}` : elementId;
433
+ });
434
+ node.params.showIds = resolvedIds.join(",");
435
+ }
436
+
437
+ // Resolve hideIds: elementId1,elementId2 → id-internalId1,id-internalId2
438
+ if (node.params.hideIds) {
439
+ const ids = node.params.hideIds.split(",").map((id) => id.trim());
440
+ const resolvedIds = ids.map((elementId) => {
441
+ const internalId = elementIdMap[elementId];
442
+ return internalId ? `id-${internalId}` : elementId;
443
+ });
444
+ node.params.hideIds = resolvedIds.join(",");
445
+ }
446
+ }
447
+
448
+ // Recursively process children
449
+ if (node.children && Array.isArray(node.children)) {
450
+ node.children.forEach((child) => resolveButtonReferences(child, elementIdMap));
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Parse the entire page tree starting from a root element
456
+ *
457
+ * @param {HTMLElement} rootElement - The root element to parse (default: find ContentNode)
458
+ * @param {Object} styleguideData - Optional styleguide data for applying fonts/colors
459
+ * @returns {Object} The pagetree JSON object
460
+ */
461
+ export function parsePageTree(rootElement = null, styleguideData = null) {
462
+ // Find the root ContentNode if not provided
463
+ if (!rootElement) {
464
+ rootElement = document.querySelector('[data-type="ContentNode"]');
465
+ }
466
+
467
+ if (!rootElement) {
468
+ console.error("No ContentNode found in document");
469
+ return null;
470
+ }
471
+
472
+ // Apply styleguide fonts and colors as data attributes before parsing
473
+ // This ensures the parser captures styleguide-applied values (fonts, paint theme colors)
474
+ const docRoot = rootElement.ownerDocument || document;
475
+ applyStyleguideDataAttributes(docRoot, styleguideData);
476
+
477
+ // Create element ID map for scroll/show-hide reference resolution
478
+ const elementIdMap = {};
479
+
480
+ // Create parseElement with styleguide data in closure for button style lookup
481
+ const parseElement = createParseElement(styleguideData, elementIdMap);
482
+
483
+ // Parse the content node
484
+ const content = parseContentNode(rootElement, parseElement);
485
+
486
+ // Resolve button scroll-target and show-ids/hide-ids references
487
+ // This converts element-id references to internal pagetree IDs with id- prefix
488
+ resolveButtonReferences(content, elementIdMap);
489
+
490
+ // Parse popup if present (cf-popup renders to .cf-popup-wrapper with data-type="ModalContainer/V1")
491
+ let popupNode = null;
492
+ const popupElement = docRoot.querySelector('.cf-popup-wrapper[data-type="ModalContainer/V1"]');
493
+ if (popupElement) {
494
+ popupNode = parseModalContainer(popupElement, parseElement);
495
+ // Also resolve button references in popup
496
+ resolveButtonReferences(popupNode, elementIdMap);
497
+ }
498
+
499
+ // Extract page settings from data attributes
500
+ const textColorRaw = rootElement.getAttribute("data-text-color") || "#334155";
501
+ const linkColorRaw = rootElement.getAttribute("data-link-color") || "#3b82f6";
502
+ const textColor = normalizeColor(textColorRaw);
503
+ const linkColor = normalizeColor(linkColorRaw);
504
+ const fontFamily = rootElement.getAttribute("data-font-family") || "";
505
+ const fontWeight = rootElement.getAttribute("data-font-weight") || "";
506
+ // Decode URL-encoded values
507
+ const headerCodeRaw = rootElement.getAttribute("data-header-code") || "";
508
+ const footerCodeRaw = rootElement.getAttribute("data-footer-code") || "";
509
+ const customCssRaw = rootElement.getAttribute("data-custom-css") || "";
510
+ const headerCode = headerCodeRaw ? decodeURIComponent(headerCodeRaw) : "";
511
+ const footerCode = footerCodeRaw ? decodeURIComponent(footerCodeRaw) : "";
512
+ const customCss = customCssRaw ? decodeURIComponent(customCssRaw) : "";
513
+
514
+ // Generate settings ID
515
+ const settingsId = generateId();
516
+
517
+ // Build settings children
518
+ const settingsChildren = [];
519
+
520
+ // page_style (CSS/fonts/colors)
521
+ const pageStyleAttrs = {
522
+ style: {
523
+ color: textColor,
524
+ },
525
+ };
526
+ // Normalize font-family to ClickFunnels format (e.g., "\"Poppins\", sans-serif")
527
+ const normalizedFontFamily = fontFamily
528
+ ? normalizeFontFamily(fontFamily)
529
+ : null;
530
+ if (normalizedFontFamily)
531
+ pageStyleAttrs.style["font-family"] = normalizedFontFamily;
532
+ if (fontWeight) pageStyleAttrs.style["font-weight"] = fontWeight;
533
+
534
+ settingsChildren.push({
535
+ id: "page_style",
536
+ type: "css",
537
+ parentId: settingsId,
538
+ fractionalIndex: "a0",
539
+ attrs: pageStyleAttrs,
540
+ selectors: {
541
+ ".elTypographyLink": {
542
+ attrs: {
543
+ style: {
544
+ color: linkColor,
545
+ },
546
+ },
547
+ params: {},
548
+ },
549
+ },
550
+ params: {},
551
+ });
552
+
553
+ // header-code
554
+ settingsChildren.push({
555
+ id: "header-code",
556
+ type: "raw",
557
+ parentId: settingsId,
558
+ fractionalIndex: "a1",
559
+ innerText: headerCode,
560
+ });
561
+
562
+ // footer-code
563
+ settingsChildren.push({
564
+ id: "footer-code",
565
+ type: "raw",
566
+ parentId: settingsId,
567
+ fractionalIndex: "a2",
568
+ innerText: footerCode,
569
+ });
570
+
571
+ // css - default CSS plus any custom CSS
572
+ const defaultCss = "";
573
+ const combinedCss = customCss ? `${defaultCss}\n\n${customCss}` : defaultCss;
574
+ settingsChildren.push({
575
+ id: "css",
576
+ type: "raw",
577
+ parentId: settingsId,
578
+ fractionalIndex: "a3",
579
+ innerText: combinedCss,
580
+ });
581
+
582
+ // Build the full pagetree structure
583
+ const pageTree = {
584
+ version: 157,
585
+ content,
586
+ settings: {
587
+ id: settingsId,
588
+ type: "settings",
589
+ version: 0,
590
+ children: settingsChildren,
591
+ },
592
+ popup: popupNode || {
593
+ id: "",
594
+ version: 0,
595
+ type: "ModalContainer/V1",
596
+ selectors: {
597
+ ".containerModal": {
598
+ attrs: {
599
+ "data-skip-corners-settings": "false",
600
+ "data-style-guide-corner": "style1",
601
+ style: { "margin-bottom": 0 },
602
+ },
603
+ },
604
+ },
605
+ },
606
+ };
607
+
608
+ return pageTree;
609
+ }
610
+
611
+ /**
612
+ * Parse and export as JSON string
613
+ *
614
+ * @param {HTMLElement} rootElement - The root element to parse
615
+ * @param {boolean} pretty - Whether to pretty-print the JSON
616
+ * @param {Object} styleguideData - Optional styleguide data for applying fonts/colors
617
+ * @returns {string} The pagetree as JSON string
618
+ */
619
+ export function exportPageTreeJSON(
620
+ rootElement = null,
621
+ pretty = true,
622
+ styleguideData = null
623
+ ) {
624
+ const pageTree = parsePageTree(rootElement, styleguideData);
625
+ if (!pageTree) return null;
626
+
627
+ return pretty ? JSON.stringify(pageTree, null, 2) : JSON.stringify(pageTree);
628
+ }
629
+
630
+ /**
631
+ * Download the pagetree as a JSON file
632
+ *
633
+ * @param {string} filename - The filename (default: 'pagetree.json')
634
+ * @param {HTMLElement} rootElement - The root element to parse
635
+ * @param {Object} styleguideData - Optional styleguide data for applying fonts/colors
636
+ */
637
+ export function downloadPageTree(
638
+ filename = "pagetree.json",
639
+ rootElement = null,
640
+ styleguideData = null
641
+ ) {
642
+ const json = exportPageTreeJSON(rootElement, true, styleguideData);
643
+ if (!json) return;
644
+
645
+ const blob = new Blob([json], { type: "application/json" });
646
+ const url = URL.createObjectURL(blob);
647
+
648
+ const a = document.createElement("a");
649
+ a.href = url;
650
+ a.download = filename;
651
+ document.body.appendChild(a);
652
+ a.click();
653
+ document.body.removeChild(a);
654
+ URL.revokeObjectURL(url);
655
+ }
656
+
657
+ /**
658
+ * Copy pagetree JSON to clipboard
659
+ *
660
+ * @param {HTMLElement} rootElement - The root element to parse
661
+ * @param {Object} styleguideData - Optional styleguide data for applying fonts/colors
662
+ * @returns {Promise<boolean>} Whether the copy was successful
663
+ */
664
+ export async function copyPageTreeToClipboard(
665
+ rootElement = null,
666
+ styleguideData = null
667
+ ) {
668
+ const json = exportPageTreeJSON(rootElement, true, styleguideData);
669
+ if (!json) return false;
670
+
671
+ try {
672
+ await navigator.clipboard.writeText(json);
673
+ return true;
674
+ } catch (err) {
675
+ console.error("Failed to copy to clipboard:", err);
676
+ return false;
677
+ }
678
+ }
679
+
680
+ // Expose global API when running in the browser
681
+ if (typeof window !== "undefined") {
682
+ window.PageTreeParser = {
683
+ parse: parsePageTree,
684
+ toJSON: exportPageTreeJSON,
685
+ download: downloadPageTree,
686
+ copyToClipboard: copyPageTreeToClipboard,
687
+ };
688
+ }
689
+
690
+ // Export all utilities for advanced usage
691
+ export {
692
+ generateId,
693
+ generateFractionalIndex,
694
+ parseValueWithUnit,
695
+ normalizeColor,
696
+ parseInlineStyle,
697
+ } from "./utils.js";
698
+
699
+ export {
700
+ parseShadow,
701
+ shadowToParams,
702
+ parseBorder,
703
+ borderToParams,
704
+ parseBackground,
705
+ backgroundToParams,
706
+ } from "./styles.js";
707
+
708
+ export { PARSER_MAP };