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/utils.js ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * ============================================================================
3
+ * PAGETREE PARSER - Utilities
4
+ * ============================================================================
5
+ *
6
+ * ID generation, fractional indexing, and common helpers.
7
+ *
8
+ * ============================================================================
9
+ */
10
+
11
+ // Use DOMPurify in browser, simple passthrough on server
12
+ // Server-side parsing is from trusted FunnelWind source
13
+ let DOMPurify = null;
14
+ if (typeof window !== 'undefined') {
15
+ import('dompurify').then(mod => { DOMPurify = mod.default; });
16
+ }
17
+
18
+ /**
19
+ * Sanitize HTML to prevent XSS attacks
20
+ * Allows only safe formatting tags for text content
21
+ */
22
+ export function sanitizeHtml(html) {
23
+ if (!html) return '';
24
+ // On server or before DOMPurify loads, return HTML as-is
25
+ // (server-side parsing is from trusted FunnelWind source)
26
+ if (!DOMPurify) return html;
27
+ return DOMPurify.sanitize(html, {
28
+ ALLOWED_TAGS: ['b', 'strong', 'i', 'em', 'u', 's', 'strike', 'a', 'br', 'span', 'li'],
29
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'style', 'class'],
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Generate a unique ClickFunnels-compatible ID
35
+ * Format: 6Z-xxxxx-0 (5 random alphanumeric chars)
36
+ */
37
+ export function generateId() {
38
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
39
+ let id = '';
40
+ for (let i = 0; i < 5; i++) {
41
+ id += chars[Math.floor(Math.random() * chars.length)];
42
+ }
43
+ return `6Z-${id}-0`;
44
+ }
45
+
46
+ /**
47
+ * Generate fractional index for ordering elements
48
+ * Sequence: a0, a1, a2... a9, aA, aB... aZ, b0, b1...
49
+ */
50
+ export function generateFractionalIndex(index) {
51
+ const BASE_62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
52
+
53
+ if (index < 36) {
54
+ // a0 through aZ (0-35)
55
+ return 'a' + BASE_62[index];
56
+ } else if (index < 72) {
57
+ // b0 through bZ (36-71)
58
+ return 'b' + BASE_62[index - 36];
59
+ } else if (index < 108) {
60
+ // c0 through cZ (72-107)
61
+ return 'c' + BASE_62[index - 72];
62
+ } else {
63
+ // For very long lists, use multi-character
64
+ const prefix = String.fromCharCode(97 + Math.floor(index / 36)); // a, b, c...
65
+ const suffix = BASE_62[index % 36];
66
+ return prefix + suffix;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Parse a CSS value with unit (e.g., "48px" -> { value: 48, unit: "px" })
72
+ */
73
+ export function parseValueWithUnit(str, defaultUnit = 'px') {
74
+ if (str === null || str === undefined) return null;
75
+
76
+ const strVal = String(str).trim();
77
+
78
+ // Handle percentage
79
+ if (strVal.endsWith('%')) {
80
+ return {
81
+ value: parseFloat(strVal),
82
+ unit: '%'
83
+ };
84
+ }
85
+
86
+ // Handle rem
87
+ if (strVal.endsWith('rem')) {
88
+ return {
89
+ value: parseFloat(strVal),
90
+ unit: 'rem'
91
+ };
92
+ }
93
+
94
+ // Handle em
95
+ if (strVal.endsWith('em')) {
96
+ return {
97
+ value: parseFloat(strVal),
98
+ unit: 'em'
99
+ };
100
+ }
101
+
102
+ // Handle px (explicit or implicit)
103
+ if (strVal.endsWith('px')) {
104
+ return {
105
+ value: parseFloat(strVal),
106
+ unit: 'px'
107
+ };
108
+ }
109
+
110
+ // Pure number
111
+ const num = parseFloat(strVal);
112
+ if (!isNaN(num)) {
113
+ return {
114
+ value: num,
115
+ unit: defaultUnit
116
+ };
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Convert color to rgb/rgba format for ClickFunnels
124
+ */
125
+ export function normalizeColor(color) {
126
+ if (!color) return null;
127
+
128
+ // Already in rgb/rgba format
129
+ if (color.startsWith('rgb')) {
130
+ return color;
131
+ }
132
+
133
+ // Hex color
134
+ if (color.startsWith('#')) {
135
+ return hexToRgb(color);
136
+ }
137
+
138
+ // Named colors or other formats - return as-is
139
+ return color;
140
+ }
141
+
142
+ /**
143
+ * Convert hex color to rgb format
144
+ */
145
+ export function hexToRgb(hex) {
146
+ // Remove # if present
147
+ hex = hex.replace('#', '');
148
+
149
+ // Handle shorthand (e.g., #fff)
150
+ if (hex.length === 3) {
151
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
152
+ }
153
+
154
+ // Handle 8-character hex with alpha
155
+ if (hex.length === 8) {
156
+ const r = parseInt(hex.substring(0, 2), 16);
157
+ const g = parseInt(hex.substring(2, 4), 16);
158
+ const b = parseInt(hex.substring(4, 6), 16);
159
+ const a = parseInt(hex.substring(6, 8), 16) / 255;
160
+ return `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})`;
161
+ }
162
+
163
+ // Standard 6-character hex
164
+ const r = parseInt(hex.substring(0, 2), 16);
165
+ const g = parseInt(hex.substring(2, 4), 16);
166
+ const b = parseInt(hex.substring(4, 6), 16);
167
+ return `rgb(${r}, ${g}, ${b})`;
168
+ }
169
+
170
+ /**
171
+ * Parse inline style string to object
172
+ */
173
+ export function parseInlineStyle(styleStr) {
174
+ if (!styleStr) return {};
175
+
176
+ const styles = {};
177
+ const parts = styleStr.split(';');
178
+
179
+ for (const part of parts) {
180
+ const colonIndex = part.indexOf(':');
181
+ if (colonIndex > -1) {
182
+ const prop = part.substring(0, colonIndex).trim();
183
+ const val = part.substring(colonIndex + 1).trim();
184
+ if (prop && val) {
185
+ styles[prop] = val;
186
+ }
187
+ }
188
+ }
189
+
190
+ return styles;
191
+ }
192
+
193
+ /**
194
+ * Get data-type attribute from element
195
+ */
196
+ export function getDataType(element) {
197
+ return element.getAttribute('data-type') || '';
198
+ }
199
+
200
+ /**
201
+ * Parse animation data attributes from element
202
+ * Returns { attrs, params } object with animation settings or empty objects if no animation
203
+ */
204
+ export function parseAnimationAttrs(element) {
205
+ const skipAnimation = element.getAttribute('data-skip-animation-settings');
206
+ if (skipAnimation !== 'false') {
207
+ return { attrs: {}, params: {} };
208
+ }
209
+
210
+ const attrs = {
211
+ 'data-skip-animation-settings': 'false',
212
+ };
213
+
214
+ const params = {};
215
+
216
+ const animationType = element.getAttribute('data-animation-type');
217
+ if (animationType) attrs['data-animation-type'] = animationType;
218
+
219
+ const animationTime = element.getAttribute('data-animation-time');
220
+ if (animationTime) {
221
+ attrs['data-animation-time'] = parseInt(animationTime, 10);
222
+ params['data-animation-time--unit'] = 'ms';
223
+ }
224
+
225
+ const animationDelay = element.getAttribute('data-animation-delay');
226
+ if (animationDelay) {
227
+ attrs['data-animation-delay'] = parseInt(animationDelay, 10);
228
+ params['data-animation-delay--unit'] = 'ms';
229
+ }
230
+
231
+ const animationTrigger = element.getAttribute('data-animation-trigger');
232
+ if (animationTrigger) attrs['data-animation-trigger'] = animationTrigger;
233
+
234
+ const animationTiming = element.getAttribute('data-animation-timing-function');
235
+ if (animationTiming) attrs['data-animation-timing-function'] = animationTiming;
236
+
237
+ const animationDirection = element.getAttribute('data-animation-direction');
238
+ if (animationDirection) attrs['data-animation-direction'] = animationDirection;
239
+
240
+ const animationOnce = element.getAttribute('data-animation-once');
241
+ if (animationOnce) attrs['data-animation-once'] = animationOnce === 'true';
242
+
243
+ const animationLoop = element.getAttribute('data-animation-loop');
244
+ if (animationLoop) attrs['data-animation-loop'] = animationLoop === 'true';
245
+
246
+ return { attrs, params };
247
+ }
248
+
249
+ /**
250
+ * Deep merge two objects
251
+ */
252
+ export function deepMerge(target, source) {
253
+ const result = { ...target };
254
+
255
+ for (const key of Object.keys(source)) {
256
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
257
+ result[key] = deepMerge(result[key] || {}, source[key]);
258
+ } else {
259
+ result[key] = source[key];
260
+ }
261
+ }
262
+
263
+ return result;
264
+ }
265
+
266
+ /**
267
+ * Extract text content from element (handles nested spans, etc.)
268
+ */
269
+ export function extractTextContent(element) {
270
+ return element.textContent || element.innerText || '';
271
+ }
272
+
273
+ /**
274
+ * Parse HTML content to ContentEditableNode children
275
+ * @param {string} html - The HTML content to parse
276
+ * @param {string|null} defaultLinkColor - Default color for links (from styleguide or data attribute)
277
+ */
278
+ export function parseHtmlToTextNodes(html, defaultLinkColor = null) {
279
+ const temp = document.createElement('div');
280
+ temp.innerHTML = sanitizeHtml(html);
281
+
282
+ const children = [];
283
+
284
+ function processNode(node) {
285
+ if (node.nodeType === Node.TEXT_NODE) {
286
+ const text = node.textContent;
287
+ if (text) {
288
+ return { type: 'text', innerText: text };
289
+ }
290
+ return null;
291
+ }
292
+
293
+ if (node.nodeType === Node.ELEMENT_NODE) {
294
+ const tagName = node.tagName.toLowerCase();
295
+
296
+ // Handle inline formatting
297
+ if (['b', 'strong'].includes(tagName)) {
298
+ return {
299
+ type: 'b',
300
+ children: Array.from(node.childNodes).map(processNode).filter(Boolean)
301
+ };
302
+ }
303
+
304
+ if (['i', 'em'].includes(tagName)) {
305
+ return {
306
+ type: 'i',
307
+ children: Array.from(node.childNodes).map(processNode).filter(Boolean)
308
+ };
309
+ }
310
+
311
+ if (tagName === 'u') {
312
+ return {
313
+ type: 'u',
314
+ children: Array.from(node.childNodes).map(processNode).filter(Boolean)
315
+ };
316
+ }
317
+
318
+ if (tagName === 'strike' || tagName === 's') {
319
+ return {
320
+ type: 'strike',
321
+ children: Array.from(node.childNodes).map(processNode).filter(Boolean)
322
+ };
323
+ }
324
+
325
+ if (tagName === 'a') {
326
+ // Parse anchor's inline style for color
327
+ const anchorStyle = node.getAttribute('style') || '';
328
+ const styleObj = parseInlineStyle(anchorStyle);
329
+
330
+ // Determine link color:
331
+ // - If defaultLinkColor is set (from paint theme data-link-color), use it
332
+ // because paint themes use CSS !important to override inline styles
333
+ // - Otherwise fall back to anchor's inline style
334
+ let linkColor = defaultLinkColor;
335
+ if (!linkColor && styleObj.color) {
336
+ linkColor = normalizeColor(styleObj.color);
337
+ }
338
+
339
+ // Build attrs object
340
+ const attrs = {
341
+ href: node.getAttribute('href') || '#',
342
+ id: `link-${generateId().slice(0, 5)}`,
343
+ target: node.getAttribute('target') || '_self',
344
+ className: 'elTypographyLink',
345
+ rel: node.getAttribute('rel') || 'noopener',
346
+ };
347
+
348
+ // Add color as inline style if present
349
+ if (linkColor) {
350
+ attrs.style = { color: linkColor };
351
+ }
352
+
353
+ return {
354
+ type: 'a',
355
+ attrs,
356
+ children: Array.from(node.childNodes).map(processNode).filter(Boolean)
357
+ };
358
+ }
359
+
360
+ if (tagName === 'br') {
361
+ return { type: 'br' };
362
+ }
363
+
364
+ if (tagName === 'span') {
365
+ return {
366
+ type: 'span',
367
+ children: Array.from(node.childNodes).map(processNode).filter(Boolean)
368
+ };
369
+ }
370
+
371
+ if (tagName === 'li') {
372
+ return {
373
+ type: 'li',
374
+ children: Array.from(node.childNodes).map(processNode).filter(Boolean)
375
+ };
376
+ }
377
+
378
+ // For other elements, just process children
379
+ const results = [];
380
+ for (const child of node.childNodes) {
381
+ const processed = processNode(child);
382
+ if (processed) results.push(processed);
383
+ }
384
+ return results.length === 1 ? results[0] : results;
385
+ }
386
+
387
+ return null;
388
+ }
389
+
390
+ for (const child of temp.childNodes) {
391
+ const processed = processNode(child);
392
+ if (processed) {
393
+ if (Array.isArray(processed)) {
394
+ children.push(...processed);
395
+ } else {
396
+ children.push(processed);
397
+ }
398
+ }
399
+ }
400
+
401
+ return children;
402
+ }