@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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.js +1072 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
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
|
+
"&": "&",
|
|
225
|
+
"<": "<",
|
|
226
|
+
">": ">",
|
|
227
|
+
""": "\"",
|
|
228
|
+
"'": "'",
|
|
229
|
+
"'": "'",
|
|
230
|
+
" ": " "
|
|
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
|