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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * ============================================================================
3
+ * PAGETREE PARSER - Text Element Parsers
4
+ * ============================================================================
5
+ *
6
+ * Headline/V1, SubHeadline/V1, Paragraph/V1
7
+ *
8
+ * ============================================================================
9
+ */
10
+
11
+ import {
12
+ generateId,
13
+ generateFractionalIndex,
14
+ parseInlineStyle,
15
+ parseValueWithUnit,
16
+ normalizeColor,
17
+ parseHtmlToTextNodes,
18
+ parseAnimationAttrs,
19
+ } from "../utils.js";
20
+
21
+ import {
22
+ parseSpacing,
23
+ spacingToAttrsAndParams,
24
+ normalizeFontWeight,
25
+ parseLineHeight,
26
+ parseTextAlign,
27
+ parseFontFamily,
28
+ } from "../styles.js";
29
+
30
+ /**
31
+ * Parse text element (Headline, SubHeadline, or Paragraph)
32
+ *
33
+ * Reads from data attributes first (most reliable), falls back to inline styles
34
+ */
35
+ function parseTextElement(
36
+ element,
37
+ parentId,
38
+ index,
39
+ type,
40
+ selector,
41
+ _styleGuideAttr
42
+ ) {
43
+ const id = generateId();
44
+ const contentEditableId = generateId();
45
+
46
+ const wrapperStyles = parseInlineStyle(element.getAttribute("style") || "");
47
+ const spacing = parseSpacing(wrapperStyles);
48
+
49
+ // Find the text element (h1, h2, p, span)
50
+ const textEl =
51
+ element.querySelector("h1, h2, h3, h4, h5, h6, p, span") || element;
52
+ const textStyles = parseInlineStyle(textEl.getAttribute("style") || "");
53
+
54
+ // Read from data attributes first (most reliable), fallback to inline styles
55
+ // data-size-resolved is set by applyStyleguideDataAttributes() when size presets are used
56
+ // This converts "xl", "l", "m", "s" presets to actual pixel values from the typescale
57
+ const sizeResolved = element.getAttribute("data-size-resolved");
58
+ const sizeAttr = element.getAttribute("data-size");
59
+ const parsedSizeResolved = sizeResolved ? parseValueWithUnit(sizeResolved) : null;
60
+ const parsedSizeAttr = sizeAttr ? parseValueWithUnit(sizeAttr) : null;
61
+ const fontSize = parsedSizeResolved || parsedSizeAttr || parseValueWithUnit(textStyles["font-size"] || "48px");
62
+
63
+ const weightAttr = element.getAttribute("data-weight");
64
+ const fontWeight = weightAttr
65
+ ? normalizeFontWeight(weightAttr)
66
+ : normalizeFontWeight(textStyles["font-weight"] || "normal");
67
+
68
+ const fontAttr = element.getAttribute("data-font");
69
+ // Normalize font-family to ClickFunnels format (e.g., "\"Poppins\", sans-serif")
70
+ const fontFamily = fontAttr
71
+ ? parseFontFamily(fontAttr)
72
+ : parseFontFamily(textStyles["font-family"]);
73
+
74
+ const colorAttr = element.getAttribute("data-color");
75
+ const color = colorAttr
76
+ ? normalizeColor(colorAttr)
77
+ : normalizeColor(textStyles.color || "#000000");
78
+
79
+ const alignAttr = element.getAttribute("data-align");
80
+ const textAlign = alignAttr || parseTextAlign(textStyles["text-align"]);
81
+
82
+ const leadingAttr = element.getAttribute("data-leading");
83
+ const lineHeight = leadingAttr
84
+ ? parseLineHeight(leadingAttr)
85
+ : parseLineHeight(textStyles["line-height"]);
86
+
87
+ const trackingAttr = element.getAttribute("data-tracking");
88
+ const letterSpacing = trackingAttr
89
+ ? parseValueWithUnit(trackingAttr, "rem")
90
+ : parseValueWithUnit(textStyles["letter-spacing"], "rem");
91
+
92
+ const transformAttr = element.getAttribute("data-transform");
93
+ const textTransform = transformAttr || textStyles["text-transform"];
94
+
95
+ // Get icon attributes
96
+ const iconAttr = element.getAttribute("data-icon");
97
+ const iconAlignAttr = element.getAttribute("data-icon-align");
98
+
99
+ // Get link color - prefer data attribute, fallback to parsing anchor elements
100
+ const linkColorAttr = element.getAttribute("data-link-color");
101
+ let linkColor = null;
102
+ if (linkColorAttr) {
103
+ linkColor = normalizeColor(linkColorAttr);
104
+ } else {
105
+ // Try to find an anchor in the content and get its color
106
+ const anchor = textEl.querySelector("a");
107
+ if (anchor) {
108
+ const anchorStyles = parseInlineStyle(anchor.getAttribute("style") || "");
109
+ if (anchorStyles.color) {
110
+ linkColor = normalizeColor(anchorStyles.color);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Build the style object for the selector
116
+ const selectorStyle = {
117
+ "font-size": fontSize ? fontSize.value : 48,
118
+ "font-weight": fontWeight,
119
+ color: color,
120
+ "text-align": textAlign,
121
+ "line-height": lineHeight ? lineHeight.value : 140,
122
+ "letter-spacing": letterSpacing ? letterSpacing.value : 0,
123
+ };
124
+
125
+ // Add font-family if present
126
+ if (fontFamily) {
127
+ selectorStyle["font-family"] = fontFamily;
128
+ }
129
+
130
+ // Determine the style-guide-override param name based on type
131
+ const styleGuideOverrideMap = {
132
+ "Headline/V1": "style-guide-override-headline",
133
+ "SubHeadline/V1": "style-guide-override-subheadline",
134
+ "Paragraph/V1": "style-guide-override-content",
135
+ };
136
+ const styleGuideOverrideParam = styleGuideOverrideMap[type];
137
+
138
+ // Parse animation attributes
139
+ const { attrs: animationAttrs, params: animationParams } = parseAnimationAttrs(element);
140
+
141
+ const node = {
142
+ type,
143
+ id,
144
+ version: 0,
145
+ parentId,
146
+ fractionalIndex: generateFractionalIndex(index),
147
+ attrs: {
148
+ style: {},
149
+ ...animationAttrs,
150
+ },
151
+ params: {
152
+ "padding-top--unit": "px",
153
+ "padding-bottom--unit": "px",
154
+ "--style-padding-horizontal--unit": "px",
155
+ "--style-padding-horizontal": 0,
156
+ ...animationParams,
157
+ },
158
+ selectors: {
159
+ [selector]: {
160
+ attrs: {
161
+ style: selectorStyle,
162
+ },
163
+ params: {
164
+ [styleGuideOverrideParam]: true,
165
+ "font-size--unit": fontSize ? fontSize.unit : "px",
166
+ "line-height--unit": "%",
167
+ "letter-spacing--unit": "rem",
168
+ },
169
+ },
170
+ },
171
+ children: [
172
+ {
173
+ type: "ContentEditableNode",
174
+ attrs: {
175
+ "data-align-selector": selector,
176
+ },
177
+ id: contentEditableId,
178
+ version: 0,
179
+ parentId: id,
180
+ fractionalIndex: "a0",
181
+ children: [],
182
+ },
183
+ ],
184
+ };
185
+
186
+ // Apply spacing to wrapper
187
+ const spacingWithDefaults = {
188
+ paddingTop: spacing.paddingTop || parseValueWithUnit("0px"),
189
+ paddingBottom: spacing.paddingBottom || parseValueWithUnit("0px"),
190
+ paddingHorizontal: spacing.paddingHorizontal || parseValueWithUnit("0px"),
191
+ marginTop: spacing.marginTop || parseValueWithUnit("0px"),
192
+ };
193
+
194
+ const { attrs: spacingAttrs, params: spacingParams } =
195
+ spacingToAttrsAndParams(spacingWithDefaults, true);
196
+ Object.assign(node.attrs.style, spacingAttrs.style);
197
+ Object.assign(node.params, spacingParams);
198
+
199
+ // Apply text-transform if present
200
+ if (textTransform) {
201
+ node.selectors[selector].attrs.style["text-transform"] = textTransform;
202
+ }
203
+
204
+ // Apply link color to selector (for CSS cascade)
205
+ if (linkColor) {
206
+ node.selectors[`${selector} .elTypographyLink`] = {
207
+ attrs: {
208
+ style: {
209
+ color: linkColor,
210
+ },
211
+ },
212
+ };
213
+ }
214
+
215
+ // Add icon attributes to params if present
216
+ if (iconAttr) {
217
+ node.params["icon"] = iconAttr;
218
+ }
219
+ if (iconAlignAttr) {
220
+ node.params["icon-align"] = iconAlignAttr;
221
+ }
222
+
223
+ // Parse text content - pass linkColor for inline anchor styles
224
+ const textContent = textEl.innerHTML;
225
+ node.children[0].children = parseHtmlToTextNodes(textContent, linkColor);
226
+
227
+ // If no children were parsed, add simple text
228
+ if (node.children[0].children.length === 0) {
229
+ node.children[0].children.push({
230
+ type: "text",
231
+ innerText: textEl.textContent || "",
232
+ });
233
+ }
234
+
235
+ return node;
236
+ }
237
+
238
+ /**
239
+ * Parse Headline/V1
240
+ */
241
+ export function parseHeadline(element, parentId, index) {
242
+ return parseTextElement(
243
+ element,
244
+ parentId,
245
+ index,
246
+ "Headline/V1",
247
+ ".elHeadline",
248
+ "data-style-guide-headline"
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Parse SubHeadline/V1
254
+ */
255
+ export function parseSubHeadline(element, parentId, index) {
256
+ return parseTextElement(
257
+ element,
258
+ parentId,
259
+ index,
260
+ "SubHeadline/V1",
261
+ ".elSubheadline",
262
+ "data-style-guide-subheadline"
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Parse Paragraph/V1
268
+ */
269
+ export function parseParagraph(element, parentId, index) {
270
+ return parseTextElement(
271
+ element,
272
+ parentId,
273
+ index,
274
+ "Paragraph/V1",
275
+ ".elParagraph",
276
+ "data-style-guide-content"
277
+ );
278
+ }
package/src/styles.js ADDED
@@ -0,0 +1,444 @@
1
+ /**
2
+ * ============================================================================
3
+ * PAGETREE PARSER - Style Parsing Helpers
4
+ * ============================================================================
5
+ *
6
+ * Parse inline styles and convert to ClickFunnels pagetree format.
7
+ *
8
+ * ============================================================================
9
+ */
10
+
11
+ import { parseValueWithUnit, normalizeColor } from './utils.js';
12
+
13
+ /**
14
+ * Shadow presets mapping (from inline shadow to CF params)
15
+ */
16
+ export const SHADOW_PRESETS = {
17
+ 'none': null,
18
+ '0 1px 2px rgba(0,0,0,0.05)': {
19
+ x: 0, y: 1, blur: 2, spread: 0, color: 'rgba(0, 0, 0, 0.05)'
20
+ },
21
+ '0 1px 3px rgba(0,0,0,0.1)': {
22
+ x: 0, y: 1, blur: 3, spread: 0, color: 'rgba(0, 0, 0, 0.1)'
23
+ },
24
+ '0 4px 6px rgba(0,0,0,0.1)': {
25
+ x: 0, y: 4, blur: 6, spread: 0, color: 'rgba(0, 0, 0, 0.1)'
26
+ },
27
+ '0 10px 15px rgba(0,0,0,0.1)': {
28
+ x: 0, y: 10, blur: 15, spread: 0, color: 'rgba(0, 0, 0, 0.1)'
29
+ },
30
+ '0 20px 25px rgba(0,0,0,0.1)': {
31
+ x: 0, y: 20, blur: 25, spread: 0, color: 'rgba(0, 0, 0, 0.1)'
32
+ },
33
+ '0 25px 50px rgba(0,0,0,0.25)': {
34
+ x: 0, y: 25, blur: 50, spread: 0, color: 'rgba(0, 0, 0, 0.25)'
35
+ },
36
+ };
37
+
38
+ /**
39
+ * Parse box-shadow value to CF params
40
+ */
41
+ export function parseShadow(shadowValue) {
42
+ if (!shadowValue || shadowValue === 'none') return null;
43
+
44
+ // Check if it matches a preset
45
+ const normalized = shadowValue.replace(/\s+/g, ' ').trim();
46
+ if (SHADOW_PRESETS[normalized]) {
47
+ return SHADOW_PRESETS[normalized];
48
+ }
49
+
50
+ // Check for inset keyword
51
+ const isInset = shadowValue.toLowerCase().includes('inset');
52
+ const shadowWithoutInset = shadowValue.replace(/inset\s*/gi, '').trim();
53
+
54
+ // Parse custom shadow: "0 4px 6px rgba(0,0,0,0.1)"
55
+ const match = shadowWithoutInset.match(
56
+ /(-?\d+(?:\.\d+)?(?:px)?)\s+(-?\d+(?:\.\d+)?(?:px)?)\s+(-?\d+(?:\.\d+)?(?:px)?)\s*(-?\d+(?:\.\d+)?(?:px)?)?\s*(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8})?/
57
+ );
58
+
59
+ if (match) {
60
+ return {
61
+ x: parseFloat(match[1]) || 0,
62
+ y: parseFloat(match[2]) || 0,
63
+ blur: parseFloat(match[3]) || 0,
64
+ spread: parseFloat(match[4]) || 0,
65
+ color: match[5] || 'rgba(0, 0, 0, 0.1)',
66
+ inset: isInset,
67
+ };
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Convert shadow params to CF pagetree format
75
+ */
76
+ export function shadowToParams(shadow) {
77
+ if (!shadow) return {};
78
+
79
+ const params = {
80
+ '--style-box-shadow-distance-x': shadow.x,
81
+ '--style-box-shadow-distance-y': shadow.y,
82
+ '--style-box-shadow-blur': shadow.blur,
83
+ '--style-box-shadow-spread': shadow.spread,
84
+ '--style-box-shadow-color': shadow.color,
85
+ '--style-box-shadow-distance-x--unit': 'px',
86
+ '--style-box-shadow-distance-y--unit': 'px',
87
+ '--style-box-shadow-blur--unit': 'px',
88
+ '--style-box-shadow-spread--unit': 'px',
89
+ };
90
+
91
+ if (shadow.inset) {
92
+ params['--style-box-shadow-style-type'] = 'inset';
93
+ }
94
+
95
+ return params;
96
+ }
97
+
98
+ /**
99
+ * Parse border properties from inline styles
100
+ */
101
+ export function parseBorder(styles) {
102
+ const result = {
103
+ width: null,
104
+ style: null,
105
+ color: null,
106
+ };
107
+
108
+ // Check for border shorthand
109
+ if (styles.border) {
110
+ const parts = styles.border.split(/\s+/);
111
+ for (const part of parts) {
112
+ if (part.match(/^\d/)) {
113
+ result.width = parseValueWithUnit(part);
114
+ } else if (['solid', 'dashed', 'dotted', 'none'].includes(part)) {
115
+ result.style = part;
116
+ } else {
117
+ result.color = normalizeColor(part);
118
+ }
119
+ }
120
+ }
121
+
122
+ // Check individual properties (override shorthand)
123
+ if (styles['border-width']) {
124
+ result.width = parseValueWithUnit(styles['border-width']);
125
+ }
126
+ if (styles['border-style']) {
127
+ result.style = styles['border-style'];
128
+ }
129
+ if (styles['border-color']) {
130
+ result.color = normalizeColor(styles['border-color']);
131
+ }
132
+
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * Convert border to CF params format
138
+ */
139
+ export function borderToParams(border) {
140
+ if (!border.width && !border.style && !border.color) return {};
141
+
142
+ const params = {};
143
+
144
+ if (border.width) {
145
+ params['--style-border-width'] = border.width.value;
146
+ params['--style-border-width--unit'] = border.width.unit;
147
+ }
148
+ if (border.style) {
149
+ params['--style-border-style'] = border.style;
150
+ }
151
+ if (border.color) {
152
+ params['--style-border-color'] = border.color;
153
+ }
154
+
155
+ return params;
156
+ }
157
+
158
+ /**
159
+ * Parse spacing (padding/margin) from inline styles
160
+ */
161
+ export function parseSpacing(styles) {
162
+ const result = {};
163
+
164
+ if (styles['padding-top']) {
165
+ result.paddingTop = parseValueWithUnit(styles['padding-top']);
166
+ }
167
+ if (styles['padding-bottom']) {
168
+ result.paddingBottom = parseValueWithUnit(styles['padding-bottom']);
169
+ }
170
+ if (styles['padding-left'] || styles['padding-right']) {
171
+ result.paddingHorizontal = parseValueWithUnit(
172
+ styles['padding-left'] || styles['padding-right']
173
+ );
174
+ }
175
+ if (styles['margin-top']) {
176
+ result.marginTop = parseValueWithUnit(styles['margin-top']);
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ /**
183
+ * Convert spacing to CF attrs/params format
184
+ */
185
+ export function spacingToAttrsAndParams(spacing, forceDefaults = false) {
186
+ const attrs = { style: {} };
187
+ const params = {};
188
+
189
+ const paddingTop = spacing.paddingTop || (forceDefaults ? parseValueWithUnit('0px') : null);
190
+ const paddingBottom = spacing.paddingBottom || (forceDefaults ? parseValueWithUnit('0px') : null);
191
+ const paddingHorizontal = spacing.paddingHorizontal || (forceDefaults ? parseValueWithUnit('0px') : null);
192
+ const marginTop = spacing.marginTop || (forceDefaults ? parseValueWithUnit('0px') : null);
193
+
194
+ if (paddingTop) {
195
+ attrs.style['padding-top'] = paddingTop.value;
196
+ params['padding-top--unit'] = paddingTop.unit;
197
+ }
198
+
199
+ if (paddingBottom) {
200
+ attrs.style['padding-bottom'] = paddingBottom.value;
201
+ params['padding-bottom--unit'] = paddingBottom.unit;
202
+ }
203
+
204
+ if (paddingHorizontal) {
205
+ params['--style-padding-horizontal'] = paddingHorizontal.value;
206
+ params['--style-padding-horizontal--unit'] = paddingHorizontal.unit;
207
+ }
208
+
209
+ if (marginTop) {
210
+ attrs.style['margin-top'] = marginTop.value;
211
+ params['margin-top--unit'] = marginTop.unit;
212
+ }
213
+
214
+ return { attrs, params };
215
+ }
216
+
217
+ /**
218
+ * Parse background from inline styles
219
+ */
220
+ export function parseBackground(styles) {
221
+ const result = {
222
+ color: null,
223
+ imageUrl: null,
224
+ gradient: null,
225
+ };
226
+
227
+ if (styles['background-color']) {
228
+ result.color = normalizeColor(styles['background-color']);
229
+ }
230
+
231
+ if (styles.background) {
232
+ // Check if it's a gradient
233
+ if (styles.background.includes('gradient')) {
234
+ result.gradient = styles.background;
235
+ } else {
236
+ // Could be color
237
+ result.color = normalizeColor(styles.background);
238
+ }
239
+ }
240
+
241
+ if (styles['background-image']) {
242
+ const urlMatch = styles['background-image'].match(/url\(['"]?([^'"]+)['"]?\)/);
243
+ if (urlMatch) {
244
+ result.imageUrl = urlMatch[1];
245
+ }
246
+ }
247
+
248
+ return result;
249
+ }
250
+
251
+ /**
252
+ * Convert background to CF params format
253
+ *
254
+ * NOTE: In ClickFunnels, gradients use --style-background-color (not --style-background-image)
255
+ * The gradient value like "linear-gradient(180deg, #6366f1 0%, #a855f7 100%)"
256
+ * goes directly into --style-background-color
257
+ *
258
+ * IMPORTANT: --style-background-image-url is always included (even as empty string)
259
+ * This is required for ClickFunnels to recognize background settings
260
+ */
261
+ export function backgroundToParams(background) {
262
+ const params = {};
263
+
264
+ // Gradients take priority and use --style-background-color
265
+ if (background.gradient) {
266
+ params['--style-background-color'] = background.gradient;
267
+ } else if (background.color) {
268
+ params['--style-background-color'] = background.color;
269
+ }
270
+
271
+ // Always include background-image-url (empty string if no image)
272
+ params['--style-background-image-url'] = background.imageUrl || '';
273
+
274
+ return params;
275
+ }
276
+
277
+ /**
278
+ * Parse border-radius from inline styles
279
+ */
280
+ export function parseBorderRadius(styles) {
281
+ if (styles['border-radius']) {
282
+ return parseValueWithUnit(styles['border-radius']);
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * Convert font-weight string to numeric value
289
+ */
290
+ export function normalizeFontWeight(weight) {
291
+ const weightMap = {
292
+ thin: '100',
293
+ extralight: '200',
294
+ light: '300',
295
+ normal: '400',
296
+ medium: '500',
297
+ semibold: '600',
298
+ bold: '700',
299
+ extrabold: '800',
300
+ black: '900',
301
+ };
302
+
303
+ if (weightMap[weight]) {
304
+ return weightMap[weight];
305
+ }
306
+
307
+ return weight;
308
+ }
309
+
310
+ /**
311
+ * Parse line-height and convert to percentage
312
+ */
313
+ export function parseLineHeight(value) {
314
+ if (!value) return null;
315
+
316
+ // Already percentage
317
+ if (String(value).endsWith('%')) {
318
+ return {
319
+ value: parseFloat(value),
320
+ unit: '%'
321
+ };
322
+ }
323
+
324
+ // Decimal multiplier (e.g., 1.5 = 150%)
325
+ const num = parseFloat(value);
326
+ if (!isNaN(num) && num > 0 && num < 5) {
327
+ return {
328
+ value: Math.round(num * 100),
329
+ unit: '%'
330
+ };
331
+ }
332
+
333
+ // Named values
334
+ const lineHeightMap = {
335
+ none: 100,
336
+ tight: 110,
337
+ snug: 120,
338
+ normal: 140,
339
+ relaxed: 160,
340
+ loose: 180,
341
+ };
342
+
343
+ if (lineHeightMap[value]) {
344
+ return {
345
+ value: lineHeightMap[value],
346
+ unit: '%'
347
+ };
348
+ }
349
+
350
+ return {
351
+ value: parseFloat(value) || 140,
352
+ unit: '%'
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Parse text-align value
358
+ */
359
+ export function parseTextAlign(value) {
360
+ if (!value) return 'center'; // CF default
361
+ const valid = ['left', 'center', 'right'];
362
+ return valid.includes(value) ? value : 'center';
363
+ }
364
+
365
+ /**
366
+ * Parse flex direction
367
+ */
368
+ export function parseFlexDirection(value) {
369
+ const map = {
370
+ row: 'row',
371
+ column: 'column',
372
+ 'row-reverse': 'row-reverse',
373
+ 'column-reverse': 'column-reverse',
374
+ };
375
+ return map[value] || 'row';
376
+ }
377
+
378
+ /**
379
+ * Parse justify-content
380
+ */
381
+ export function parseJustifyContent(value) {
382
+ const valid = [
383
+ 'flex-start', 'center', 'flex-end',
384
+ 'space-between', 'space-around', 'space-evenly'
385
+ ];
386
+ return valid.includes(value) ? value : 'center';
387
+ }
388
+
389
+ /**
390
+ * Parse align-items
391
+ */
392
+ export function parseAlignItems(value) {
393
+ const valid = ['flex-start', 'center', 'flex-end', 'stretch', 'baseline'];
394
+ return valid.includes(value) ? value : 'center';
395
+ }
396
+
397
+ /**
398
+ * Normalize font-family to ClickFunnels format
399
+ * ClickFunnels expects: "\"FontName\", sans-serif" (with escaped quotes around font name)
400
+ *
401
+ * Input formats handled:
402
+ * - "Poppins" -> "\"Poppins\", sans-serif"
403
+ * - "Noto Sans JP" -> "\"Noto Sans JP\", sans-serif"
404
+ * - "'Poppins', sans-serif" -> "\"Poppins\", sans-serif"
405
+ * - "\"Poppins\", sans-serif" -> "\"Poppins\", sans-serif" (already correct)
406
+ * - "Poppins, sans-serif" -> "\"Poppins\", sans-serif"
407
+ */
408
+ export function normalizeFontFamily(fontFamily) {
409
+ if (!fontFamily) return null;
410
+
411
+ // Trim whitespace
412
+ let font = fontFamily.trim();
413
+
414
+ // If already in ClickFunnels format (starts with escaped quote or actual quote char for the font name)
415
+ if (font.startsWith('"') && font.includes(',')) {
416
+ // Already has quotes, return as-is
417
+ return font;
418
+ }
419
+
420
+ // Remove surrounding single or double quotes if present
421
+ font = font.replace(/^['"]|['"]$/g, '');
422
+
423
+ // Check if it has a fallback already (contains comma)
424
+ if (font.includes(',')) {
425
+ // Extract the primary font name
426
+ const parts = font.split(',');
427
+ const primaryFont = parts[0].trim().replace(/^['"]|['"]$/g, '');
428
+ const fallback = parts.slice(1).map(p => p.trim()).join(', ') || 'sans-serif';
429
+
430
+ return `"${primaryFont}", ${fallback}`;
431
+ }
432
+
433
+ // Just a font name, add quotes and fallback
434
+ return `"${font}", sans-serif`;
435
+ }
436
+
437
+ /**
438
+ * Parse font-family from inline style value
439
+ * Returns the normalized ClickFunnels format
440
+ */
441
+ export function parseFontFamily(value) {
442
+ if (!value) return null;
443
+ return normalizeFontFamily(value);
444
+ }