@templatical/import-chamaileon 0.36.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/dist/index.js ADDED
@@ -0,0 +1,1072 @@
1
+ import { DEFAULT_TEMPLATE_DEFAULTS, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createTitleBlock, createVideoBlock, findOpenTagEnd, generateId } from "@templatical/types";
2
+ //#region src/normalize.ts
3
+ function isUnset(value) {
4
+ if (value === null || value === void 0) return true;
5
+ if (typeof value !== "string") return false;
6
+ const trimmed = value.trim();
7
+ return trimmed === "" || trimmed === "url()" || trimmed.toLowerCase() === "transparent";
8
+ }
9
+ function camelKey(key) {
10
+ return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
11
+ }
12
+ function isVariableObject(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value) && ("reference" in value || "default" in value);
14
+ }
15
+ function unwrapValue(value, variables, stats) {
16
+ if (!isVariableObject(value)) return value;
17
+ const resolved = !isUnset(value.default) ? value.default : (() => {
18
+ const ref = typeof value.reference === "string" ? value.reference : void 0;
19
+ if (!ref) return void 0;
20
+ const found = variables.find((v) => v.name === ref);
21
+ return found && !isUnset(found.value) ? found.value : void 0;
22
+ })();
23
+ if (stats && !isUnset(resolved)) stats.resolvedVariables += 1;
24
+ return resolved;
25
+ }
26
+ function readMap(raw, variables, stats) {
27
+ const out = {};
28
+ if (!raw) return out;
29
+ for (const [key, value] of Object.entries(raw)) {
30
+ const unwrapped = unwrapValue(value, variables, stats);
31
+ if (isUnset(unwrapped)) continue;
32
+ out[camelKey(key)] = unwrapped;
33
+ }
34
+ return out;
35
+ }
36
+ function readStyle(node, variables, stats) {
37
+ return readMap(node.style, variables, stats);
38
+ }
39
+ function readAttrs(node, variables, stats) {
40
+ return readMap(node.attrs, variables, stats);
41
+ }
42
+ function styleValue(style, key) {
43
+ return style[camelKey(key)];
44
+ }
45
+ //#endregion
46
+ //#region src/attribute-parser.ts
47
+ /**
48
+ * Parse a px-like value into a rounded integer. Returns `undefined` when unset
49
+ * or for units the block model cannot express (em, rem, %).
50
+ *
51
+ * Trims first, then matches with no surrounding `\s*` so a failing match cannot
52
+ * backtrack across trailing spaces.
53
+ */
54
+ function parsePx(value) {
55
+ if (isUnset(value)) return void 0;
56
+ if (typeof value === "number") return Number.isFinite(value) ? Math.round(value) : void 0;
57
+ if (typeof value !== "string") return void 0;
58
+ const trimmed = value.trim();
59
+ const match = /^(-?\d+(?:\.\d+)?)(?:px)?$/.exec(trimmed);
60
+ return match ? Math.round(parseFloat(match[1])) : void 0;
61
+ }
62
+ const NAMED_COLORS = {
63
+ black: "#000000",
64
+ white: "#ffffff",
65
+ red: "#ff0000",
66
+ green: "#008000",
67
+ blue: "#0000ff",
68
+ yellow: "#ffff00",
69
+ cyan: "#00ffff",
70
+ magenta: "#ff00ff",
71
+ gray: "#808080",
72
+ grey: "#808080",
73
+ silver: "#c0c0c0",
74
+ maroon: "#800000",
75
+ olive: "#808000",
76
+ lime: "#00ff00",
77
+ aqua: "#00ffff",
78
+ teal: "#008080",
79
+ navy: "#000080",
80
+ fuchsia: "#ff00ff",
81
+ purple: "#800080",
82
+ orange: "#ffa500",
83
+ pink: "#ffc0cb"
84
+ };
85
+ function rgbToHex(r, g, b) {
86
+ const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
87
+ const hex = (n) => clamp(n).toString(16).padStart(2, "0");
88
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
89
+ }
90
+ /**
91
+ * Normalize a colour to 6-digit lowercase hex.
92
+ *
93
+ * Returns `undefined` for unset inputs (including transparent) and for anything
94
+ * unrecognised — optional colour fields stay omitted rather than empty string.
95
+ */
96
+ function parseColor(value) {
97
+ if (isUnset(value)) return void 0;
98
+ if (typeof value !== "string") return void 0;
99
+ const trimmed = value.trim().toLowerCase();
100
+ if (trimmed === "transparent" || trimmed === "inherit" || trimmed === "none") return void 0;
101
+ if (/^#[0-9a-f]{6}$/.test(trimmed)) return trimmed;
102
+ if (/^#[0-9a-f]{3}$/.test(trimmed)) {
103
+ const [, r, g, b] = trimmed;
104
+ return `#${r}${r}${g}${g}${b}${b}`;
105
+ }
106
+ const rgb = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/);
107
+ if (rgb) return rgbToHex(+rgb[1], +rgb[2], +rgb[3]);
108
+ return NAMED_COLORS[trimmed];
109
+ }
110
+ /** Read split padding sides from a normalized style map; missing sides are 0. */
111
+ function parsePadding(style) {
112
+ const side = (key) => parsePx(styleValue(style, key)) ?? 0;
113
+ return {
114
+ top: side("paddingTop"),
115
+ right: side("paddingRight"),
116
+ bottom: side("paddingBottom"),
117
+ left: side("paddingLeft")
118
+ };
119
+ }
120
+ /** Narrow an alignment to the three the block model accepts. */
121
+ function parseAlignment(value, fallback) {
122
+ if (typeof value !== "string") return fallback;
123
+ const v = value.trim().toLowerCase();
124
+ return v === "left" || v === "center" || v === "right" ? v : fallback;
125
+ }
126
+ function isSpace(ch) {
127
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r";
128
+ }
129
+ function skipSpaces(s, i) {
130
+ while (i < s.length && isSpace(s[i])) i++;
131
+ return i;
132
+ }
133
+ /**
134
+ * `"2px solid #00a591"` — width, style, colour.
135
+ *
136
+ * Linear: a regex whose last two tokens are "spaces, then the rest of the
137
+ * string" is polynomial on a long run of spaces after the style word
138
+ * (`js/polynomial-redos`). Every character here is visited at most once.
139
+ */
140
+ function parseCssBorder(value) {
141
+ if (isUnset(value) || typeof value !== "string") return void 0;
142
+ const s = value.trim();
143
+ let i = 0;
144
+ const numStart = i;
145
+ while (i < s.length && s[i] >= "0" && s[i] <= "9") i++;
146
+ if (i < s.length && s[i] === ".") {
147
+ i++;
148
+ while (i < s.length && s[i] >= "0" && s[i] <= "9") i++;
149
+ }
150
+ if (i === numStart) return void 0;
151
+ const width = Math.round(parseFloat(s.slice(numStart, i)));
152
+ if (s.slice(i, i + 2).toLowerCase() !== "px") return void 0;
153
+ i += 2;
154
+ i = skipSpaces(s, i);
155
+ const styleStart = i;
156
+ while (i < s.length && !isSpace(s[i])) i++;
157
+ const styleWord = s.slice(styleStart, i).toLowerCase();
158
+ if (styleWord !== "solid" && styleWord !== "dashed" && styleWord !== "dotted") return;
159
+ i = skipSpaces(s, i);
160
+ if (i >= s.length) return void 0;
161
+ const color = parseColor(s.slice(i));
162
+ if (color === void 0 || width === 0) return void 0;
163
+ return {
164
+ width,
165
+ style: styleWord,
166
+ color
167
+ };
168
+ }
169
+ /** Parse a 2.0 divider `attrs.lineStyle` string into thickness/lineStyle/color. */
170
+ function parseLineStyle(value) {
171
+ const border = parseCssBorder(value);
172
+ if (!border) return void 0;
173
+ return {
174
+ thickness: border.width,
175
+ lineStyle: border.style,
176
+ color: border.color
177
+ };
178
+ }
179
+ /** Strip quotes and return the first family in a stack. */
180
+ function firstFamily(value) {
181
+ if (isUnset(value) || typeof value !== "string") return void 0;
182
+ const family = value.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
183
+ return family === "" ? void 0 : family;
184
+ }
185
+ //#endregion
186
+ //#region src/content-inference.ts
187
+ /**
188
+ * A single heading element wrapping the whole content, captured with its level
189
+ * and inner markup. Anchored at both ends so a heading with a sibling does not
190
+ * match — that case is a paragraph.
191
+ */
192
+ const SOLE_HEADING = /^<h([1-6])(?:\s[^>]*)?>([\s\S]*)<\/h\1>$/i;
193
+ const BLOCK_ROOT = /^<(p|h[1-6]|ul|ol|blockquote|div|table)\b/i;
194
+ /**
195
+ * Resolve a Chamaileon `attrs.text` string to a Title or a Paragraph.
196
+ *
197
+ * Chamaileon has no menu or table construct for this arm, so this is a two-way
198
+ * choice — the MJML importer needs four arms because `mj-text` is the output
199
+ * of four different block renderers there. Paragraph is the terminal arm and
200
+ * always reachable, so this function is total.
201
+ */
202
+ function inferTextBlock(content) {
203
+ const trimmed = (content ?? "").trim();
204
+ if (trimmed === "") return {
205
+ kind: "paragraph",
206
+ html: "<p></p>"
207
+ };
208
+ const heading = trimmed.match(SOLE_HEADING);
209
+ if (heading) {
210
+ const source = Number(heading[1]);
211
+ return {
212
+ kind: "title",
213
+ level: Math.min(source, 4),
214
+ inner: heading[2].trim(),
215
+ ...source > 4 ? { clampedFrom: source } : {}
216
+ };
217
+ }
218
+ return {
219
+ kind: "paragraph",
220
+ html: BLOCK_ROOT.test(trimmed) ? trimmed : `<p>${trimmed}</p>`
221
+ };
222
+ }
223
+ const ENTITIES = {
224
+ "&amp;": "&",
225
+ "&lt;": "<",
226
+ "&gt;": ">",
227
+ "&quot;": "\"",
228
+ "&#39;": "'",
229
+ "&apos;": "'",
230
+ "&nbsp;": " "
231
+ };
232
+ /**
233
+ * Remove every `<tag>` from `html`, keeping the text between them.
234
+ *
235
+ * A single-pass linear scan rather than `/<[^>]*>/g`: that regex is
236
+ * polynomial over input with many `<` starts and no closing `>`, since
237
+ * `[^>]*` backtracks at every one of them — `"<".repeat(n)` is quadratic in
238
+ * `n`. `findOpenTagEnd` (from `@templatical/types`) resolves each `<` with a
239
+ * bounded, quote-aware forward scan instead, so the walk stays O(n): every
240
+ * character is visited at most once by `indexOf` and once by
241
+ * `findOpenTagEnd`, because the cursor only moves forward and an
242
+ * unterminated tag ends the walk rather than retrying from inside it. On
243
+ * that unterminated case the remainder of the string — the dangling `<`
244
+ * included — is emitted as-is, matching what `/<[^>]*>/g` does with
245
+ * malformed markup: it can never complete a match without a `>`, so the
246
+ * text is left untouched from that point on.
247
+ */
248
+ function removeTags(html) {
249
+ let out = "";
250
+ let i = 0;
251
+ while (i < html.length) {
252
+ const open = html.indexOf("<", i);
253
+ if (open === -1) {
254
+ out += html.substring(i);
255
+ break;
256
+ }
257
+ out += html.substring(i, open);
258
+ const tagEnd = findOpenTagEnd(html, open + 1);
259
+ if (tagEnd === -1) {
260
+ out += html.substring(open);
261
+ break;
262
+ }
263
+ i = tagEnd + 1;
264
+ }
265
+ return out;
266
+ }
267
+ /**
268
+ * The visible text of a markup fragment, for a button label — `ButtonBlock.text`
269
+ * is a plain string, so the markup Chamaileon stores in `attrs.text` cannot
270
+ * travel.
271
+ */
272
+ function stripTags(html) {
273
+ if (!html) return "";
274
+ return removeTags(html).replace(/&(?:amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => ENTITIES[m] ?? m).replace(/\s+/g, " ").trim();
275
+ }
276
+ //#endregion
277
+ //#region src/social-mapper.ts
278
+ /**
279
+ * Exhaustive over `SocialPlatform` on purpose: adding a member to that union
280
+ * without adding it here is a compile error, so this cannot silently fall back
281
+ * to "website" for a platform the block model gained.
282
+ */
283
+ const KNOWN_PLATFORMS = {
284
+ facebook: true,
285
+ twitter: true,
286
+ instagram: true,
287
+ linkedin: true,
288
+ youtube: true,
289
+ tiktok: true,
290
+ pinterest: true,
291
+ email: true,
292
+ whatsapp: true,
293
+ telegram: true,
294
+ discord: true,
295
+ snapchat: true,
296
+ reddit: true,
297
+ github: true,
298
+ dribbble: true,
299
+ behance: true,
300
+ website: true
301
+ };
302
+ const ICON_SIZES = [
303
+ [24, "small"],
304
+ [32, "medium"],
305
+ [48, "large"]
306
+ ];
307
+ const KNOWN_STYLES = /* @__PURE__ */ new Set([
308
+ "solid",
309
+ "outlined",
310
+ "rounded",
311
+ "square",
312
+ "circle"
313
+ ]);
314
+ function normalizePlatform(raw) {
315
+ const cleaned = raw.trim().toLowerCase();
316
+ if (!cleaned) return null;
317
+ return cleaned in KNOWN_PLATFORMS ? cleaned : null;
318
+ }
319
+ function nearestSize(px) {
320
+ let best = ICON_SIZES[1];
321
+ let gap = Infinity;
322
+ for (const candidate of ICON_SIZES) {
323
+ const d = Math.abs(candidate[0] - px);
324
+ if (d < gap) {
325
+ gap = d;
326
+ best = candidate;
327
+ }
328
+ }
329
+ return {
330
+ size: best[1],
331
+ exact: gap === 0
332
+ };
333
+ }
334
+ function asString$2(value) {
335
+ return typeof value === "string" ? value : void 0;
336
+ }
337
+ function asIconStyle(value) {
338
+ if (typeof value !== "string") return void 0;
339
+ const v = value.trim().toLowerCase();
340
+ return KNOWN_STYLES.has(v) ? v : void 0;
341
+ }
342
+ /**
343
+ * Convert a Chamaileon `social` leaf into a `SocialIconsBlock`.
344
+ *
345
+ * `attrs.elements` is the source of truth for which icons render and in what
346
+ * order. A row of images is not sniffed as social — only `type === "social"`.
347
+ */
348
+ function convertSocial(node, ctx) {
349
+ const style = readStyle(node, ctx.variables, ctx.stats);
350
+ const attrs = readAttrs(node, ctx.variables, ctx.stats);
351
+ const rawElements = attrs.elements;
352
+ const elements = Array.isArray(rawElements) ? rawElements : [];
353
+ if (elements.length === 0) return {
354
+ block: null,
355
+ entry: report$1("social", null, "skipped", "social has no elements")
356
+ };
357
+ const notes = [];
358
+ const icons = [];
359
+ for (const item of elements) {
360
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
361
+ const el = item;
362
+ const rawType = asString$2(el.type) ?? "";
363
+ const platform = normalizePlatform(rawType);
364
+ if (!platform) notes.push(`Unrecognised social platform "${rawType}" mapped to "website".`);
365
+ icons.push({
366
+ id: generateId(),
367
+ platform: platform ?? "website",
368
+ url: asString$2(el.link) ?? ""
369
+ });
370
+ }
371
+ if (icons.length === 0) return {
372
+ block: null,
373
+ entry: report$1("social", null, "skipped", "social has no elements")
374
+ };
375
+ let iconSize;
376
+ const sizePx = parsePx(attrs.size);
377
+ if (sizePx !== void 0) {
378
+ const resolved = nearestSize(sizePx);
379
+ iconSize = resolved.size;
380
+ if (!resolved.exact) notes.push(`Icon size ${attrs.size} is not one of 24/32/48px; resolved to "${resolved.size}".`);
381
+ }
382
+ const iconStyle = asIconStyle(attrs.color) ?? asIconStyle(attrs.iconSet);
383
+ const spacing = parsePx(attrs.spacing);
384
+ const block = createSocialIconsBlock({
385
+ icons,
386
+ align: parseAlignment(attrs.align ?? style.align, "center"),
387
+ ...iconSize ? { iconSize } : {},
388
+ ...iconStyle ? { iconStyle } : {},
389
+ ...spacing !== void 0 ? { spacing } : {},
390
+ styles: { padding: parsePadding(style) }
391
+ });
392
+ const visibility = readVisibility$2(attrs);
393
+ if (visibility) block.visibility = visibility;
394
+ return {
395
+ block,
396
+ entry: report$1("social", "social", notes.length > 0 ? "approximated" : "converted", notes.length > 0 ? notes.join(" ") : void 0)
397
+ };
398
+ }
399
+ function readVisibility$2(attrs) {
400
+ const hideOnMobile = attrs.hideOnMobile === true;
401
+ const hideOnDesktop = attrs.hideOnDesktop === true;
402
+ if (!hideOnMobile && !hideOnDesktop) return void 0;
403
+ return {
404
+ desktop: !hideOnDesktop,
405
+ mobile: !hideOnMobile
406
+ };
407
+ }
408
+ function report$1(sourceTag, templaticalBlockType, status, note) {
409
+ return {
410
+ sourceTag,
411
+ templaticalBlockType,
412
+ status,
413
+ ...note ? { note } : {}
414
+ };
415
+ }
416
+ //#endregion
417
+ //#region src/block-mapper.ts
418
+ const LOOP_TYPES = /* @__PURE__ */ new Set([
419
+ "block-level-loop",
420
+ "block-level-conditional",
421
+ "loop",
422
+ "conditional",
423
+ "branch"
424
+ ]);
425
+ function isLoopType(type) {
426
+ return typeof type === "string" && LOOP_TYPES.has(type);
427
+ }
428
+ function readLoopExpression(node, ctx) {
429
+ const value = readAttrs(node, ctx.variables, ctx.stats).expression;
430
+ return typeof value === "string" ? value : void 0;
431
+ }
432
+ function skippedLoopEntry(type, expression) {
433
+ return {
434
+ sourceTag: type,
435
+ templaticalBlockType: null,
436
+ status: "skipped",
437
+ note: expression ? `${type} ${expression}` : type
438
+ };
439
+ }
440
+ function markLoopApproximated(entries, from, type, expression) {
441
+ const extra = expression ? `${type} ${expression}` : type;
442
+ for (let i = from; i < entries.length; i++) {
443
+ const entry = entries[i];
444
+ if (entry.status === "converted") entry.status = "approximated";
445
+ entry.note = entry.note ? `${entry.note} ${extra}` : extra;
446
+ }
447
+ }
448
+ /**
449
+ * Convert one Chamaileon leaf into a Templatical block.
450
+ *
451
+ * Structure nodes (`fullwidth` / `box` / `multicolumn` / `column`)
452
+ * html-fallback if they reach this function.
453
+ */
454
+ function convertLeaf(node, ctx) {
455
+ switch (node.type) {
456
+ case "text": return convertText(node, ctx);
457
+ case "typed-text": return convertTypedText(node, ctx);
458
+ case "button": return convertButton(node, ctx);
459
+ case "image": return convertImage(node, ctx, "converted");
460
+ case "dynamic-image": return convertImage(node, ctx, "approximated", "dynamic-image imported as an image block");
461
+ case "divider": return convertDivider(node, ctx);
462
+ case "video": return convertVideo(node, ctx);
463
+ case "code": return convertCode(node, ctx);
464
+ case "social": return convertSocial(node, ctx);
465
+ default: return convertUnknown(node, ctx);
466
+ }
467
+ }
468
+ function convertText(node, ctx) {
469
+ const { style, attrs } = readNode(node, ctx);
470
+ const raw = asString$1(attrs.text);
471
+ const inferred = inferTextBlock(raw);
472
+ if (inferred.kind === "paragraph") return finish(createParagraphBlock({
473
+ content: inferred.html,
474
+ ...paddingStyles(style)
475
+ }), attrs, report("text", "paragraph", "converted"));
476
+ const heading = headingLooks(style, inferred.level, ctx);
477
+ return finish(createTitleBlock({
478
+ content: inferred.inner,
479
+ level: inferred.level,
480
+ textAlign: parseAlignment(alignFromMarkup(raw) ?? attrs.align ?? style.align, "left"),
481
+ ...heading,
482
+ ...paddingStyles(style)
483
+ }), attrs, inferred.clampedFrom ? report("text", "title", "approximated", `Heading level h${inferred.clampedFrom} clamped to 4 — Templatical titles support h1-h4.`) : report("text", "title", "converted"));
484
+ }
485
+ function convertTypedText(node, ctx) {
486
+ const { style, attrs } = readNode(node, ctx);
487
+ const raw = asString$1(attrs.text);
488
+ const inferred = inferTextBlock(raw);
489
+ const subType = asString$1(style.subType)?.trim().toLowerCase() ?? "paragraph";
490
+ const titleLevel = typedTitleLevel(subType);
491
+ if (titleLevel !== void 0) {
492
+ const level = inferred.kind === "title" ? inferred.level : titleLevel;
493
+ const color = parseColor(styleValue(style, "color"));
494
+ const fontFamily = firstFamily(styleValue(style, "fontFamily"));
495
+ return finish(createTitleBlock({
496
+ content: inferred.kind === "title" ? inferred.inner : stripTags(raw),
497
+ level,
498
+ textAlign: parseAlignment(style.align ?? attrs.align ?? alignFromMarkup(raw), "left"),
499
+ ...color ? { color } : {},
500
+ ...fontFamily ? { fontFamily } : {},
501
+ ...paddingStyles(style)
502
+ }), attrs, report("typed-text", "title", "converted"));
503
+ }
504
+ const list = subType === "list";
505
+ return finish(createParagraphBlock({
506
+ content: inferred.kind === "paragraph" ? inferred.html : raw?.trim() || "<p></p>",
507
+ ...paddingStyles(style)
508
+ }), attrs, list ? report("typed-text", "paragraph", "approximated", "typed-text list imported as a paragraph") : report("typed-text", "paragraph", "converted"));
509
+ }
510
+ function convertButton(node, ctx) {
511
+ const { style, attrs } = readNode(node, ctx);
512
+ const fill = parseColor(styleValue(style, "backgroundColor"));
513
+ const textColor = parseColor(styleValue(style, "color"));
514
+ const borderRadius = parsePx(styleValue(style, "borderRadius"));
515
+ const fontSize = parsePx(styleValue(style, "fontSize"));
516
+ const fontFamily = firstFamily(styleValue(style, "fontFamily"));
517
+ const block = createButtonBlock({
518
+ text: stripTags(asString$1(attrs.text)),
519
+ url: asString$1(attrs.href) ?? "",
520
+ ...fill ? { backgroundColor: fill } : {},
521
+ ...textColor ? { textColor } : {},
522
+ ...borderRadius !== void 0 ? { borderRadius } : {},
523
+ ...fontSize !== void 0 ? { fontSize } : {},
524
+ ...fontFamily ? { fontFamily } : {},
525
+ align: parseAlignment(attrs.align ?? style.align, "center"),
526
+ ...paddingStyles(style)
527
+ });
528
+ if (!fill) block.backgroundColor = "#ffffff";
529
+ return finish(block, attrs, fill ? report("button", "button", "converted") : report("button", "button", "approximated", "outlined button has no Templatical equivalent"));
530
+ }
531
+ function convertImage(node, ctx, status, note) {
532
+ const { style, attrs } = readNode(node, ctx);
533
+ const sourceTag = node.type ?? "image";
534
+ const src = asString$1(attrs.src) ?? asString$1(styleValue(style, "src")) ?? "";
535
+ const width = resolvedWidth(style, ctx.columnWidth);
536
+ const linkUrl = asString$1(attrs.link);
537
+ const borderRadius = parsePx(styleValue(style, "borderRadius"));
538
+ return finish(createImageBlock({
539
+ src,
540
+ alt: asString$1(attrs.altText) ?? "",
541
+ ...width !== void 0 ? { width } : {},
542
+ align: parseAlignment(attrs.align ?? style.align, "center"),
543
+ ...linkUrl ? { linkUrl } : {},
544
+ ...borderRadius && borderRadius > 0 ? { borderRadius } : {},
545
+ ...paddingStyles(style)
546
+ }), attrs, report(sourceTag, "image", status, note));
547
+ }
548
+ function convertDivider(node, ctx) {
549
+ const { style, attrs } = readNode(node, ctx);
550
+ const fromLine = parseLineStyle(attrs.lineStyle);
551
+ const thickness = fromLine?.thickness ?? parsePx(styleValue(style, "width"));
552
+ const lineStyle = fromLine?.lineStyle ?? asLineStyle(style.type);
553
+ const color = fromLine?.color ?? parseColor(styleValue(style, "color"));
554
+ return finish(createDividerBlock({
555
+ ...thickness !== void 0 ? { thickness } : {},
556
+ ...lineStyle ? { lineStyle } : {},
557
+ ...color ? { color } : {},
558
+ width: "full",
559
+ ...paddingStyles(style)
560
+ }), attrs, report("divider", "divider", "converted"));
561
+ }
562
+ function convertVideo(node, ctx) {
563
+ const { style, attrs } = readNode(node, ctx);
564
+ const width = resolvedWidth(style, ctx.columnWidth);
565
+ const alt = asString$1(attrs.altText);
566
+ return finish(createVideoBlock({
567
+ url: videoUrl(attrs),
568
+ thumbnailUrl: asString$1(attrs.src) ?? "",
569
+ ...alt !== void 0 ? { alt } : {},
570
+ ...width !== void 0 ? { width } : {},
571
+ align: parseAlignment(attrs.align ?? style.align, "center"),
572
+ ...paddingStyles(style)
573
+ }), attrs, report("video", "video", "converted"));
574
+ }
575
+ function videoUrl(attrs) {
576
+ const link = asString$1(attrs.link);
577
+ if (link) return link;
578
+ const videoId = asString$1(attrs.videoId);
579
+ if (!videoId) return "";
580
+ const provider = asString$1(attrs.videoProvider)?.trim().toLowerCase();
581
+ if (provider === "youtube") return `https://www.youtube.com/watch?v=${videoId}`;
582
+ if (provider === "vimeo") return `https://vimeo.com/${videoId}`;
583
+ return "";
584
+ }
585
+ function convertCode(node, ctx) {
586
+ const { style, attrs } = readNode(node, ctx);
587
+ return finish(createHtmlBlock({
588
+ content: firstSetContent(attrs.html, attrs.code, attrs.content),
589
+ ...paddingStyles(style)
590
+ }), attrs, report("code", "html", "converted"));
591
+ }
592
+ function convertUnknown(node, ctx) {
593
+ const { style, attrs } = readNode(node, ctx);
594
+ const sourceTag = node.type ?? "unknown";
595
+ return finish(createHtmlBlock({
596
+ content: JSON.stringify(node),
597
+ ...paddingStyles(style)
598
+ }), attrs, report(sourceTag, "html", "html-fallback", `${sourceTag} has no Templatical block equivalent; the original node is preserved as JSON.`));
599
+ }
600
+ function readNode(node, ctx) {
601
+ return {
602
+ style: readStyle(node, ctx.variables, ctx.stats),
603
+ attrs: readAttrs(node, ctx.variables, ctx.stats)
604
+ };
605
+ }
606
+ function paddingStyles(style) {
607
+ return { styles: { padding: parsePadding(style) } };
608
+ }
609
+ function finish(block, attrs, entry) {
610
+ const visibility = readVisibility$1(attrs);
611
+ if (visibility) block.visibility = visibility;
612
+ return {
613
+ block,
614
+ entry
615
+ };
616
+ }
617
+ function readVisibility$1(attrs) {
618
+ const hideOnMobile = attrs.hideOnMobile === true;
619
+ const hideOnDesktop = attrs.hideOnDesktop === true;
620
+ if (!hideOnMobile && !hideOnDesktop) return void 0;
621
+ return {
622
+ desktop: !hideOnDesktop,
623
+ mobile: !hideOnMobile
624
+ };
625
+ }
626
+ function report(sourceTag, templaticalBlockType, status, note) {
627
+ return {
628
+ sourceTag,
629
+ templaticalBlockType,
630
+ status,
631
+ ...note ? { note } : {}
632
+ };
633
+ }
634
+ function asString$1(value) {
635
+ return typeof value === "string" ? value : void 0;
636
+ }
637
+ function firstSetContent(...values) {
638
+ for (const value of values) {
639
+ if (isUnset(value)) continue;
640
+ if (typeof value === "string") return value;
641
+ return JSON.stringify(value);
642
+ }
643
+ return "";
644
+ }
645
+ function resolvedWidth(style, columnWidth) {
646
+ const px = parsePx(styleValue(style, "width"));
647
+ if (px === void 0) return void 0;
648
+ return px >= columnWidth ? "full" : px;
649
+ }
650
+ function asLineStyle(value) {
651
+ if (typeof value !== "string") return void 0;
652
+ const v = value.trim().toLowerCase();
653
+ return v === "solid" || v === "dashed" || v === "dotted" ? v : void 0;
654
+ }
655
+ function typedTitleLevel(subType) {
656
+ if (subType === "title" || subType === "heading1") return 1;
657
+ if (subType === "heading2") return 2;
658
+ if (subType === "heading3") return 3;
659
+ }
660
+ function headingLooks(style, level, ctx) {
661
+ const nested = readNested(style[`h${level}`], ctx);
662
+ const color = parseColor(style[`h${level}Color`] ?? nested.color);
663
+ const fontFamily = firstFamily(style[`h${level}FontFamily`] ?? nested.fontFamily);
664
+ return {
665
+ ...color ? { color } : {},
666
+ ...fontFamily ? { fontFamily } : {}
667
+ };
668
+ }
669
+ function readNested(value, ctx) {
670
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
671
+ const out = {};
672
+ for (const [key, raw] of Object.entries(value)) {
673
+ const unwrapped = unwrapValue(raw, ctx.variables, ctx.stats);
674
+ if (isUnset(unwrapped)) continue;
675
+ out[camelKey(key)] = unwrapped;
676
+ }
677
+ return out;
678
+ }
679
+ function alignFromMarkup(html) {
680
+ if (!html) return void 0;
681
+ return /text-align\s*:\s*(left|center|right)/i.exec(html)?.[1]?.toLowerCase();
682
+ }
683
+ //#endregion
684
+ //#region src/column-layout.ts
685
+ const LAYOUT_SHAPES = [
686
+ {
687
+ layout: "1",
688
+ percents: [100]
689
+ },
690
+ {
691
+ layout: "2",
692
+ percents: [50, 50]
693
+ },
694
+ {
695
+ layout: "1-2",
696
+ percents: [33.33, 66.67]
697
+ },
698
+ {
699
+ layout: "2-1",
700
+ percents: [66.67, 33.33]
701
+ },
702
+ {
703
+ layout: "3",
704
+ percents: [
705
+ 33.33,
706
+ 33.33,
707
+ 33.34
708
+ ]
709
+ }
710
+ ];
711
+ const WIDTH_TOLERANCE = 2;
712
+ const COLUMN_COUNT = {
713
+ "1": 1,
714
+ "2": 2,
715
+ "3": 3,
716
+ "2-1": 2,
717
+ "1-2": 2
718
+ };
719
+ function columnPixels(layout, container) {
720
+ switch (layout) {
721
+ case "2": return [container / 2, container / 2];
722
+ case "3": return [
723
+ container / 3,
724
+ container / 3,
725
+ container / 3
726
+ ];
727
+ case "1-2": return [container / 3, container * 2 / 3];
728
+ case "2-1": return [container * 2 / 3, container / 3];
729
+ default: return [container];
730
+ }
731
+ }
732
+ /**
733
+ * Resolve column widths to one of the five layouts `ColumnLayout` allows.
734
+ *
735
+ * `exact: false` means the caller reports `approximated` — Chamaileon permits
736
+ * any column count at any width and this union permits five shapes, so this is
737
+ * the importer's main irreducible loss.
738
+ */
739
+ function matchColumnLayout(percents) {
740
+ const count = percents.length;
741
+ if (count === 0) return {
742
+ layout: "1",
743
+ exact: true
744
+ };
745
+ if (percents.every((p) => p === null)) {
746
+ if (count === 1) return {
747
+ layout: "1",
748
+ exact: true
749
+ };
750
+ if (count === 2) return {
751
+ layout: "2",
752
+ exact: true
753
+ };
754
+ if (count === 3) return {
755
+ layout: "3",
756
+ exact: true
757
+ };
758
+ return {
759
+ layout: "3",
760
+ exact: false
761
+ };
762
+ }
763
+ const known = percents.filter((p) => p !== null);
764
+ const unsetCount = count - known.length;
765
+ const remainder = unsetCount === 0 ? 0 : Math.max(0, 100 - known.reduce((a, b) => a + b, 0)) / unsetCount;
766
+ const resolved = percents.map((p) => p ?? remainder);
767
+ const sameCount = LAYOUT_SHAPES.filter((s) => s.percents.length === count);
768
+ for (const shape of sameCount) if (shape.percents.every((want, i) => Math.abs(want - resolved[i]) <= WIDTH_TOLERANCE)) return {
769
+ layout: shape.layout,
770
+ exact: true
771
+ };
772
+ const candidates = sameCount.length > 0 ? sameCount : LAYOUT_SHAPES.filter((s) => s.layout === "3");
773
+ let best = candidates[0];
774
+ let bestError = Infinity;
775
+ for (const shape of candidates) {
776
+ const error = shape.percents.reduce((sum, want, i) => sum + Math.abs(want - (resolved[i] ?? 0)), 0);
777
+ if (error < bestError) {
778
+ bestError = error;
779
+ best = shape;
780
+ }
781
+ }
782
+ return {
783
+ layout: best.layout,
784
+ exact: false
785
+ };
786
+ }
787
+ /** Convert absolute px column widths to percents of `bodyWidth`. */
788
+ function widthsToPercents(widthsPx, bodyWidth) {
789
+ return widthsPx.map((w) => w === void 0 ? null : w / bodyWidth * 100);
790
+ }
791
+ //#endregion
792
+ //#region src/section-builder.ts
793
+ /**
794
+ * Build a `SectionBlock` from a Chamaileon `fullwidth`. Returns an array so
795
+ * an empty fullwidth produces nothing.
796
+ *
797
+ * Boxes flatten into the parent. A nested `multicolumn` flattens into the
798
+ * parent column — never a nested `type: "section"`.
799
+ */
800
+ function buildFullwidth(node, ctx, entries) {
801
+ const style = readStyle(node, ctx.variables, ctx.stats);
802
+ const attrs = readAttrs(node, ctx.variables, ctx.stats);
803
+ const contentFill = parseColor(styleValue(style, "contentBackgroundColor"));
804
+ const outerFill = parseColor(styleValue(style, "backgroundColor"));
805
+ const original = node.children ?? [];
806
+ const sole = original.length === 1 ? original[0] : void 0;
807
+ const copySoleFill = sole?.type === "box" && isPaintedBox(sole, ctx) && contentFill === void 0;
808
+ const flattened = flattenBoxes(original, ctx, entries, copySoleFill ? sole : void 0);
809
+ if (flattened.length === 0) return [];
810
+ const mcIndex = flattened.findIndex((child) => child.type === "multicolumn");
811
+ const reasons = [];
812
+ const section = createSectionBlock();
813
+ if (mcIndex === -1) {
814
+ section.columns = "1";
815
+ section.children = [convertColumnChildren(flattened, withColumnWidth(ctx, ctx.bodyWidth), entries)];
816
+ } else {
817
+ const driving = flattened[mcIndex];
818
+ const before = flattened.slice(0, mcIndex);
819
+ const after = flattened.slice(mcIndex + 1);
820
+ if (before.length > 0 || after.length > 0) reasons.push("fullwidth mixes a multicolumn with sibling leaves; siblings were placed in column 0.");
821
+ const columns = (driving.children ?? []).filter((child) => child.type === "column");
822
+ const widthsPx = columns.map((column) => parsePx(styleValue(readStyle(column, ctx.variables, ctx.stats), "width")));
823
+ const { layout, exact } = matchColumnLayout(widthsToPercents(widthsPx, ctx.bodyWidth));
824
+ if (!exact) reasons.push(`Column widths ${formatWidths(widthsPx)} have no exact Templatical layout; resolved to "${layout}".`);
825
+ const stacking = asString(readAttrs(driving, ctx.variables, ctx.stats).stacking);
826
+ if (stacking === "none") section.stackOnMobile = false;
827
+ else if (stacking !== void 0 && stacking !== "left-on-top") reasons.push(`unknown stacking "${stacking}" omitted`);
828
+ const slots = COLUMN_COUNT[layout];
829
+ const pixels = columnPixels(layout, ctx.bodyWidth);
830
+ const children = Array.from({ length: slots }, () => []);
831
+ const slotCtx = (slot) => withColumnWidth(ctx, Math.round(pixels[slot] ?? ctx.bodyWidth));
832
+ children[0].push(...convertColumnChildren(before, slotCtx(0), entries));
833
+ columns.forEach((column, index) => {
834
+ const slot = Math.min(index, slots - 1);
835
+ children[slot].push(...convertColumnChildren(column.children ?? [], slotCtx(slot), entries));
836
+ });
837
+ children[0].push(...convertColumnChildren(after, slotCtx(0), entries));
838
+ section.columns = layout;
839
+ section.children = children;
840
+ }
841
+ const soleFill = copySoleFill ? parseColor(styleValue(readStyle(sole, ctx.variables, ctx.stats), "backgroundColor")) : void 0;
842
+ const fill = contentFill ?? soleFill;
843
+ section.styles.padding = contentPadding(style);
844
+ if (fill) section.styles.backgroundColor = fill;
845
+ if (outerFill) section.wrapper = { backgroundColor: outerFill };
846
+ const visibility = readVisibility(attrs);
847
+ if (visibility) section.visibility = visibility;
848
+ entries.push({
849
+ sourceTag: "fullwidth",
850
+ templaticalBlockType: "section",
851
+ status: reasons.length > 0 ? "approximated" : "converted",
852
+ ...reasons.length > 0 ? { note: reasons.join(" ") } : {}
853
+ });
854
+ return [section];
855
+ }
856
+ function flattenBoxes(nodes, ctx, entries, silent) {
857
+ const out = [];
858
+ for (const child of nodes) {
859
+ if (child.type === "placeholder") continue;
860
+ if (child.type !== "box") {
861
+ out.push(child);
862
+ continue;
863
+ }
864
+ if (child !== silent && isPaintedBox(child, ctx)) reportPaintedBox(child, ctx, entries);
865
+ out.push(...flattenBoxes(child.children ?? [], ctx, entries, void 0));
866
+ }
867
+ return out;
868
+ }
869
+ function convertColumnChildren(nodes, ctx, entries) {
870
+ const blocks = [];
871
+ for (const child of nodes) {
872
+ if (child.type === "placeholder") continue;
873
+ if (child.type === "box") {
874
+ if (isPaintedBox(child, ctx)) reportPaintedBox(child, ctx, entries);
875
+ blocks.push(...convertColumnChildren(child.children ?? [], ctx, entries));
876
+ continue;
877
+ }
878
+ if (child.type === "multicolumn") {
879
+ const innerColumns = (child.children ?? []).filter((column) => column.type === "column");
880
+ entries.push({
881
+ sourceTag: "multicolumn",
882
+ templaticalBlockType: null,
883
+ status: "approximated",
884
+ note: `nested multicolumn flattened (${innerColumns.length} columns)`
885
+ });
886
+ for (const column of innerColumns) blocks.push(...convertColumnChildren(column.children ?? [], ctx, entries));
887
+ continue;
888
+ }
889
+ if (child.type === "column") {
890
+ blocks.push(...convertColumnChildren(child.children ?? [], ctx, entries));
891
+ continue;
892
+ }
893
+ if (isLoopType(child.type)) {
894
+ const type = child.type ?? "loop";
895
+ const expression = readLoopExpression(child, ctx);
896
+ const kids = child.children ?? [];
897
+ if (kids.length === 0) {
898
+ entries.push(skippedLoopEntry(type, expression));
899
+ continue;
900
+ }
901
+ const from = entries.length;
902
+ blocks.push(...convertColumnChildren(kids, ctx, entries));
903
+ markLoopApproximated(entries, from, type, expression);
904
+ continue;
905
+ }
906
+ const converted = convertLeaf(child, ctx);
907
+ entries.push(converted.entry);
908
+ if (converted.block) blocks.push(converted.block);
909
+ }
910
+ return blocks;
911
+ }
912
+ function isPaintedBox(node, ctx) {
913
+ const style = readStyle(node, ctx.variables, ctx.stats);
914
+ if (parseColor(styleValue(style, "backgroundColor"))) return true;
915
+ const padding = parsePadding(style);
916
+ if (padding.top !== 0 || padding.right !== 0 || padding.bottom !== 0 || padding.left !== 0) return true;
917
+ const radius = parsePx(styleValue(style, "borderRadius"));
918
+ return radius !== void 0 && radius > 0;
919
+ }
920
+ function reportPaintedBox(node, ctx, entries) {
921
+ const fill = parseColor(styleValue(readStyle(node, ctx.variables, ctx.stats), "backgroundColor"));
922
+ entries.push({
923
+ sourceTag: "box",
924
+ templaticalBlockType: null,
925
+ status: "approximated",
926
+ note: fill ? `box fill ${fill} dropped` : "box padding/radius dropped"
927
+ });
928
+ }
929
+ function contentPadding(style) {
930
+ return {
931
+ top: parsePx(styleValue(style, "contentPaddingTop")) ?? 0,
932
+ right: parsePx(styleValue(style, "contentPaddingRight")) ?? 0,
933
+ bottom: parsePx(styleValue(style, "contentPaddingBottom")) ?? 0,
934
+ left: parsePx(styleValue(style, "contentPaddingLeft")) ?? 0
935
+ };
936
+ }
937
+ function withColumnWidth(ctx, columnWidth) {
938
+ return {
939
+ ...ctx,
940
+ columnWidth
941
+ };
942
+ }
943
+ function formatWidths(widthsPx) {
944
+ return widthsPx.map((width) => width === void 0 ? "auto" : `${width}px`).join(", ");
945
+ }
946
+ function readVisibility(attrs) {
947
+ const hideOnMobile = attrs.hideOnMobile === true;
948
+ const hideOnDesktop = attrs.hideOnDesktop === true;
949
+ if (!hideOnMobile && !hideOnDesktop) return void 0;
950
+ return {
951
+ desktop: !hideOnDesktop,
952
+ mobile: !hideOnMobile
953
+ };
954
+ }
955
+ function asString(value) {
956
+ return typeof value === "string" ? value : void 0;
957
+ }
958
+ //#endregion
959
+ //#region src/converter.ts
960
+ const INVALID_MESSAGE = "Invalid Chamaileon template: expected the document JSON object from getDocument().";
961
+ const WRONG_BODY_MESSAGE = "Invalid Chamaileon template: expected body.type to be \"body\".";
962
+ const EMPTY_WARNING = "No convertible content was found in the Chamaileon document. Check that body.children holds at least one fullwidth.";
963
+ /**
964
+ * `DEFAULT_TEMPLATE_DEFAULTS` is typed `Partial<TemplateSettings>` so a
965
+ * consumer can override any subset, but its own literal always sets the six
966
+ * required fields. Narrowing once here is what lets each fallback come out as
967
+ * `number`/`string`/`boolean` rather than `| undefined`.
968
+ */
969
+ const REQUIRED_DEFAULTS = DEFAULT_TEMPLATE_DEFAULTS;
970
+ /**
971
+ * Convert a Chamaileon `getDocument()` tree into a Templatical template.
972
+ *
973
+ * The input is the document object itself — `{ body, variables?, … }` —
974
+ * not `getEmailHtml()` markup and not an API envelope. That is documented
975
+ * rather than sniffed: guessing between the persist JSON and compiled HTML
976
+ * would send the HTML through the wrong package.
977
+ *
978
+ * @example
979
+ * ```ts
980
+ * const document = await editorInstance.methods.getDocument();
981
+ * const { content, report } = convertChamaileonTemplate(document);
982
+ * ```
983
+ */
984
+ function convertChamaileonTemplate(doc) {
985
+ const root = typeof doc === "string" ? safeParse(doc) : doc;
986
+ if (typeof root !== "object" || root === null || Array.isArray(root)) throw new Error(INVALID_MESSAGE);
987
+ const document = root;
988
+ const body = document.body;
989
+ if (!body || body.type !== "body") throw new Error(WRONG_BODY_MESSAGE);
990
+ const entries = [];
991
+ const warnings = [];
992
+ const blocks = [];
993
+ const stats = { resolvedVariables: 0 };
994
+ const variables = document.variables ?? [];
995
+ const bodyStyle = readStyle(body, variables, stats);
996
+ const width = parsePx(styleValue(bodyStyle, "bodyWidth")) ?? REQUIRED_DEFAULTS.width;
997
+ const backgroundColor = parseColor(styleValue(bodyStyle, "backgroundColor")) ?? REQUIRED_DEFAULTS.backgroundColor;
998
+ const previewRaw = typeof document.previewText === "string" ? document.previewText.trim() : "";
999
+ if (typeof document.subjectLine === "string" ? document.subjectLine.trim() : "") warnings.push(`Dropped subjectLine ("${document.subjectLine}") — Templatical templates have no subject-line field.`);
1000
+ const fontFiles = document.fontFiles;
1001
+ if (fontFiles && typeof fontFiles === "object" && !Array.isArray(fontFiles) && Object.keys(fontFiles).length > 0) warnings.push("Dropped fontFiles — Templatical templates have no document-level font-file table.");
1002
+ const ctx = {
1003
+ bodyWidth: width,
1004
+ columnWidth: width,
1005
+ variables,
1006
+ warnings,
1007
+ stats
1008
+ };
1009
+ for (const child of body.children ?? []) convertBodyChild(child, ctx, entries, blocks);
1010
+ if (stats.resolvedVariables > 0) warnings.push(`Resolved ${stats.resolvedVariables} colour/image variables to their default values.`);
1011
+ if (blocks.length === 0) warnings.push(EMPTY_WARNING);
1012
+ const defaults = createDefaultTemplateContent();
1013
+ return {
1014
+ content: {
1015
+ ...defaults,
1016
+ blocks,
1017
+ settings: {
1018
+ ...defaults.settings,
1019
+ width,
1020
+ backgroundColor,
1021
+ ...previewRaw ? { preheaderText: previewRaw } : {}
1022
+ }
1023
+ },
1024
+ report: {
1025
+ entries,
1026
+ warnings,
1027
+ summary: {
1028
+ total: entries.length,
1029
+ converted: entries.filter((e) => e.status === "converted").length,
1030
+ approximated: entries.filter((e) => e.status === "approximated").length,
1031
+ htmlFallback: entries.filter((e) => e.status === "html-fallback").length,
1032
+ skipped: entries.filter((e) => e.status === "skipped").length
1033
+ }
1034
+ }
1035
+ };
1036
+ }
1037
+ function convertBodyChild(node, ctx, entries, blocks) {
1038
+ if (node.type === "fullwidth") {
1039
+ blocks.push(...buildFullwidth(node, ctx, entries));
1040
+ return;
1041
+ }
1042
+ if (isLoopType(node.type)) {
1043
+ convertLoop(node, ctx, entries, blocks);
1044
+ return;
1045
+ }
1046
+ const converted = convertLeaf(node, ctx);
1047
+ entries.push(converted.entry);
1048
+ if (converted.block) blocks.push(converted.block);
1049
+ }
1050
+ function convertLoop(node, ctx, entries, blocks) {
1051
+ const type = node.type ?? "loop";
1052
+ const expression = readLoopExpression(node, ctx);
1053
+ const children = node.children ?? [];
1054
+ if (children.length === 0) {
1055
+ entries.push(skippedLoopEntry(type, expression));
1056
+ return;
1057
+ }
1058
+ const from = entries.length;
1059
+ for (const child of children) convertBodyChild(child, ctx, entries, blocks);
1060
+ markLoopApproximated(entries, from, type, expression);
1061
+ }
1062
+ function safeParse(source) {
1063
+ try {
1064
+ return JSON.parse(source);
1065
+ } catch {
1066
+ throw new Error(INVALID_MESSAGE);
1067
+ }
1068
+ }
1069
+ //#endregion
1070
+ export { convertChamaileonTemplate };
1071
+
1072
+ //# sourceMappingURL=index.js.map