@templatical/import-mjml 0.31.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 +36 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +1498 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
import { load } from "cheerio";
|
|
2
|
+
import { DEFAULT_TEMPLATE_DEFAULTS, RICH_TEXT_SPACING, SYNTAX_PRESETS, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTableBlock, createTitleBlock, generateId } from "@templatical/types";
|
|
3
|
+
//#region src/attribute-resolver.ts
|
|
4
|
+
/**
|
|
5
|
+
* A node's tag name, lowercased.
|
|
6
|
+
*
|
|
7
|
+
* The parser configuration this package uses (`xmlMode: false`, set in
|
|
8
|
+
* `converter.ts`) already lowercases every tag at parse time, so this call is
|
|
9
|
+
* a no-op on that parser's output. It remains the one place every tag
|
|
10
|
+
* comparison in this package goes through, so a comparison stays correct
|
|
11
|
+
* regardless of the active parser configuration — a bare `$("mj-body")` would
|
|
12
|
+
* otherwise miss a document that shouts its tags.
|
|
13
|
+
*/
|
|
14
|
+
function tagOf(node) {
|
|
15
|
+
if (!node) return "";
|
|
16
|
+
return node.tagName?.toLowerCase() ?? "";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Every element with the given tag name, matched case-insensitively.
|
|
20
|
+
*/
|
|
21
|
+
function findByTag($, tag) {
|
|
22
|
+
const wanted = tag.toLowerCase();
|
|
23
|
+
return $("*").filter((_, el) => tagOf(el) === wanted);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* An element's element children.
|
|
27
|
+
*
|
|
28
|
+
* `.children()` already excludes text and comment nodes; the tag filter here
|
|
29
|
+
* guards against a node whose `tagName` is undefined, not against either of
|
|
30
|
+
* those.
|
|
31
|
+
*/
|
|
32
|
+
function childElements($el, $) {
|
|
33
|
+
return $el.children().toArray().filter((node) => tagOf(node) !== "").map((node) => $(node));
|
|
34
|
+
}
|
|
35
|
+
function attrsOf($el) {
|
|
36
|
+
const raw = $el.attr();
|
|
37
|
+
if (!raw) return {};
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const [key, value] of Object.entries(raw)) out[key.toLowerCase()] = value;
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read `mj-head > mj-attributes` into the three buckets the cascade resolves
|
|
44
|
+
* against. Called once per document; `resolveAttributes` is then pure lookup.
|
|
45
|
+
*/
|
|
46
|
+
function buildAttributeCascade($) {
|
|
47
|
+
const cascade = {
|
|
48
|
+
all: Object.create(null),
|
|
49
|
+
byTag: Object.create(null),
|
|
50
|
+
byClass: Object.create(null)
|
|
51
|
+
};
|
|
52
|
+
const containers = findByTag($, "mj-attributes").toArray();
|
|
53
|
+
for (const container of containers) {
|
|
54
|
+
const $container = $(container);
|
|
55
|
+
for (const $child of childElements($container, $)) {
|
|
56
|
+
const tag = tagOf($child[0]);
|
|
57
|
+
const attrs = attrsOf($child);
|
|
58
|
+
if (tag === "mj-all") {
|
|
59
|
+
Object.assign(cascade.all, attrs);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (tag === "mj-class") {
|
|
63
|
+
const { name, ...rest } = attrs;
|
|
64
|
+
if (!name) continue;
|
|
65
|
+
cascade.byClass[name] = {
|
|
66
|
+
...cascade.byClass[name] ?? {},
|
|
67
|
+
...rest
|
|
68
|
+
};
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
cascade.byTag[tag] = {
|
|
72
|
+
...cascade.byTag[tag] ?? {},
|
|
73
|
+
...attrs
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return cascade;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The first two cascade layers alone — `mj-all` then the per-tag default —
|
|
81
|
+
* with neither a named `mj-class` nor the element's own inline attributes on
|
|
82
|
+
* top. This is the ambient value a tag inherits before the element sets
|
|
83
|
+
* anything of its own, which is what {@link ownAttr} compares against.
|
|
84
|
+
*/
|
|
85
|
+
function resolveTagDefaults(tag, cascade) {
|
|
86
|
+
return {
|
|
87
|
+
...cascade.all,
|
|
88
|
+
...cascade.byTag[tag] ?? {}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* An element's effective attributes, highest precedence last:
|
|
93
|
+
* `mj-all` → per-tag default → each named `mj-class` → the element's own
|
|
94
|
+
* inline attributes.
|
|
95
|
+
*
|
|
96
|
+
* MJML's built-in component defaults are deliberately not modelled — the block
|
|
97
|
+
* factories in `@templatical/types` supply that floor instead, which keeps this
|
|
98
|
+
* module from becoming a copy of MJML's component table.
|
|
99
|
+
*/
|
|
100
|
+
function resolveAttributes($el, cascade) {
|
|
101
|
+
const own = attrsOf($el);
|
|
102
|
+
const resolved = resolveTagDefaults(tagOf($el[0]), cascade);
|
|
103
|
+
const classNames = (own["mj-class"] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
104
|
+
for (const name of classNames) Object.assign(resolved, cascade.byClass[name] ?? {});
|
|
105
|
+
Object.assign(resolved, own);
|
|
106
|
+
delete resolved["mj-class"];
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A resolved attribute value, but only when the element is the reason it has
|
|
111
|
+
* that value — a named `mj-class` counts as the element opting in, but a bare
|
|
112
|
+
* `mj-all`/per-tag cascade default does not.
|
|
113
|
+
*
|
|
114
|
+
* This reverses the renderer's inherit-by-omission convention for `mj-text`'s
|
|
115
|
+
* `color` and `font-family`: `renderers/title.ts`, `table.ts` and `menu.ts`
|
|
116
|
+
* all emit that attribute only when the block sets its own value, otherwise
|
|
117
|
+
* leaving the element to inherit the document default from
|
|
118
|
+
* `<mj-attributes>` (`renderers/title.ts:31`). `resolveAttributes` flattens
|
|
119
|
+
* that default onto every element of the tag regardless, so reading `attrs`
|
|
120
|
+
* directly cannot tell "the block set this" from "the document default
|
|
121
|
+
* reached this element too" — and treating the latter as the former invents
|
|
122
|
+
* a per-block override the source never made.
|
|
123
|
+
*
|
|
124
|
+
* `resolved === ambient` also covers an element that repeats the ambient
|
|
125
|
+
* value on purpose: the two cases render identically, so which one happened
|
|
126
|
+
* is not observable in the output and dropping it costs nothing (the same
|
|
127
|
+
* reasoning as §8.3b's image-width and §8.4b's paragraph-gap defaults).
|
|
128
|
+
*/
|
|
129
|
+
function ownAttr(attrs, key, tag, cascade) {
|
|
130
|
+
const resolved = attrs[key];
|
|
131
|
+
if (resolved === void 0) return void 0;
|
|
132
|
+
return resolved === resolveTagDefaults(tag, cascade)[key] ? void 0 : resolved;
|
|
133
|
+
}
|
|
134
|
+
const HIDE_DESKTOP = "tpl-hide-desktop";
|
|
135
|
+
const HIDE_MOBILE = "tpl-hide-mobile";
|
|
136
|
+
/**
|
|
137
|
+
* Marks a rendered title or paragraph's rich-text spacing — mirrors
|
|
138
|
+
* `RICH_TEXT_CSS_CLASS` in `packages/renderer/src/rich-text.ts`. `title.ts`
|
|
139
|
+
* and `paragraph.ts` are the only two renderers that pass a second argument
|
|
140
|
+
* to `getCssClassAttr`, so every rendered title and paragraph carries this
|
|
141
|
+
* class on `css-class` alongside any visibility markers.
|
|
142
|
+
*/
|
|
143
|
+
const RICH_TEXT_CSS_CLASS = "tpl-rich-text";
|
|
144
|
+
/**
|
|
145
|
+
* Matches the per-block paragraph-gap class the same two renderers append,
|
|
146
|
+
* e.g. `tpl-rich-text-8` (`richTextGapClass` in `rich-text.ts`). The gap
|
|
147
|
+
* accepts a decimal (`tpl-rich-text-8.5`) because `ParagraphBlock.paragraphSpacing`
|
|
148
|
+
* is a plain `number` that a headless caller can set to a fractional value,
|
|
149
|
+
* even though the editor UI clamps it to an integer. Negative gaps are out of
|
|
150
|
+
* scope — the renderer never emits one, so there is nothing here that needs to
|
|
151
|
+
* recognise it.
|
|
152
|
+
*
|
|
153
|
+
* Shared by `readForeignCssClasses` (which classes to exclude) and
|
|
154
|
+
* `readParagraphGap` (which class to decode), so the two can never disagree
|
|
155
|
+
* about what a gap class looks like.
|
|
156
|
+
*/
|
|
157
|
+
const RICH_TEXT_GAP_CLASS = /^tpl-rich-text-(\d+(?:\.\d+)?)$/;
|
|
158
|
+
function cssClasses(attrs) {
|
|
159
|
+
return (attrs["css-class"] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Reverse of the renderer's `getCssClassAttr` (`packages/renderer/src/visibility.ts`).
|
|
163
|
+
* `css-class` also carries the rich-text markers below (`RICH_TEXT_CSS_CLASS`,
|
|
164
|
+
* `RICH_TEXT_GAP_CLASS`) — this function reads only the two visibility
|
|
165
|
+
* classes off it and leaves those alone.
|
|
166
|
+
*
|
|
167
|
+
* Returns `undefined` when neither class is present — absence means visible
|
|
168
|
+
* everywhere, and writing `{ desktop: true, mobile: true }` instead would put a
|
|
169
|
+
* redundant key in every imported block.
|
|
170
|
+
*/
|
|
171
|
+
function readVisibility(attrs) {
|
|
172
|
+
const classes = cssClasses(attrs);
|
|
173
|
+
const hideDesktop = classes.includes(HIDE_DESKTOP);
|
|
174
|
+
const hideMobile = classes.includes(HIDE_MOBILE);
|
|
175
|
+
if (!hideDesktop && !hideMobile) return void 0;
|
|
176
|
+
return {
|
|
177
|
+
desktop: !hideDesktop,
|
|
178
|
+
mobile: !hideMobile
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The paragraph gap encoded on a rendered title or paragraph's `css-class` —
|
|
183
|
+
* reverse of `richTextGapClass` (`packages/renderer/src/rich-text.ts`), e.g.
|
|
184
|
+
* `8` from `tpl-rich-text-8`.
|
|
185
|
+
*
|
|
186
|
+
* Returns `null` when no gap class is present. Callers must not confuse that
|
|
187
|
+
* with a gap of `0`, which is a legitimate value in its own right.
|
|
188
|
+
*/
|
|
189
|
+
function readParagraphGap(attrs) {
|
|
190
|
+
for (const name of cssClasses(attrs)) {
|
|
191
|
+
const match = RICH_TEXT_GAP_CLASS.exec(name);
|
|
192
|
+
if (match) return parseFloat(match[1]);
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Classes on `css-class` that carry no Templatical meaning. The caller warns
|
|
198
|
+
* about these rather than dropping them silently: they are consumer CSS with no
|
|
199
|
+
* home in the block model, and a template that relies on them will render
|
|
200
|
+
* differently after import.
|
|
201
|
+
*
|
|
202
|
+
* Excludes the renderer's own rich-text markers alongside the two visibility
|
|
203
|
+
* classes, so importing a template the renderer itself produced does not
|
|
204
|
+
* report a title or paragraph's own spacing class as foreign. A consumer's
|
|
205
|
+
* own `tpl-`-prefixed class is not one of these markers and is still reported.
|
|
206
|
+
*/
|
|
207
|
+
function readForeignCssClasses(attrs) {
|
|
208
|
+
return cssClasses(attrs).filter((name) => name !== HIDE_DESKTOP && name !== HIDE_MOBILE && name !== RICH_TEXT_CSS_CLASS && !RICH_TEXT_GAP_CLASS.test(name));
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/attribute-parser.ts
|
|
212
|
+
/**
|
|
213
|
+
* Parses a px-like MJML attribute value (`"12px"`, `"12"`, `12`) into a rounded
|
|
214
|
+
* integer. Returns 0 for missing or unparseable input, and for units the block
|
|
215
|
+
* model cannot express (em, rem, %) — a caller that needs to tell "absent" from
|
|
216
|
+
* "0" must check the raw attribute itself.
|
|
217
|
+
*/
|
|
218
|
+
function parsePxValue(value) {
|
|
219
|
+
if (value === void 0 || value === null || value === "") return 0;
|
|
220
|
+
if (typeof value === "number") return Math.round(value);
|
|
221
|
+
const match = value.match(/^\s*(-?\d+(?:\.\d+)?)\s*(?:px)?\s*$/);
|
|
222
|
+
return match ? Math.round(parseFloat(match[1])) : 0;
|
|
223
|
+
}
|
|
224
|
+
const NAMED_COLORS = {
|
|
225
|
+
black: "#000000",
|
|
226
|
+
white: "#ffffff",
|
|
227
|
+
red: "#ff0000",
|
|
228
|
+
green: "#008000",
|
|
229
|
+
blue: "#0000ff",
|
|
230
|
+
yellow: "#ffff00",
|
|
231
|
+
cyan: "#00ffff",
|
|
232
|
+
magenta: "#ff00ff",
|
|
233
|
+
gray: "#808080",
|
|
234
|
+
grey: "#808080",
|
|
235
|
+
silver: "#c0c0c0",
|
|
236
|
+
maroon: "#800000",
|
|
237
|
+
olive: "#808000",
|
|
238
|
+
lime: "#00ff00",
|
|
239
|
+
aqua: "#00ffff",
|
|
240
|
+
teal: "#008080",
|
|
241
|
+
navy: "#000080",
|
|
242
|
+
fuchsia: "#ff00ff",
|
|
243
|
+
purple: "#800080",
|
|
244
|
+
orange: "#ffa500",
|
|
245
|
+
pink: "#ffc0cb"
|
|
246
|
+
};
|
|
247
|
+
function rgbToHex(r, g, b) {
|
|
248
|
+
const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
|
|
249
|
+
const hex = (n) => clamp(n).toString(16).padStart(2, "0");
|
|
250
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Normalizes a colour value to a 6-digit lowercase hex string.
|
|
254
|
+
*
|
|
255
|
+
* Returns `""` for transparent/inherit/none and for anything unrecognised.
|
|
256
|
+
* The empty string is the block model's "unset" — the colour pickers clear to
|
|
257
|
+
* it — so returning it is meaningfully different from returning a default.
|
|
258
|
+
*/
|
|
259
|
+
function parseColor(value) {
|
|
260
|
+
if (!value) return "";
|
|
261
|
+
const trimmed = value.trim().toLowerCase();
|
|
262
|
+
if (trimmed === "transparent" || trimmed === "inherit" || trimmed === "none") return "";
|
|
263
|
+
if (/^#[0-9a-f]{6}$/.test(trimmed)) return trimmed;
|
|
264
|
+
if (/^#[0-9a-f]{3}$/.test(trimmed)) {
|
|
265
|
+
const r = trimmed[1];
|
|
266
|
+
const g = trimmed[2];
|
|
267
|
+
const b = trimmed[3];
|
|
268
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
269
|
+
}
|
|
270
|
+
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/);
|
|
271
|
+
if (rgbMatch) return rgbToHex(parseInt(rgbMatch[1], 10), parseInt(rgbMatch[2], 10), parseInt(rgbMatch[3], 10));
|
|
272
|
+
if (NAMED_COLORS[trimmed]) return NAMED_COLORS[trimmed];
|
|
273
|
+
return "";
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Parses an MJML `padding` shorthand (1-4 values, CSS order) into a
|
|
277
|
+
* SpacingValue.
|
|
278
|
+
*/
|
|
279
|
+
function parsePaddingShorthand(value) {
|
|
280
|
+
if (!value) return {
|
|
281
|
+
top: 0,
|
|
282
|
+
right: 0,
|
|
283
|
+
bottom: 0,
|
|
284
|
+
left: 0
|
|
285
|
+
};
|
|
286
|
+
const values = value.trim().split(/\s+/).map((p) => parsePxValue(p));
|
|
287
|
+
switch (values.length) {
|
|
288
|
+
case 1: return {
|
|
289
|
+
top: values[0],
|
|
290
|
+
right: values[0],
|
|
291
|
+
bottom: values[0],
|
|
292
|
+
left: values[0]
|
|
293
|
+
};
|
|
294
|
+
case 2: return {
|
|
295
|
+
top: values[0],
|
|
296
|
+
right: values[1],
|
|
297
|
+
bottom: values[0],
|
|
298
|
+
left: values[1]
|
|
299
|
+
};
|
|
300
|
+
case 3: return {
|
|
301
|
+
top: values[0],
|
|
302
|
+
right: values[1],
|
|
303
|
+
bottom: values[2],
|
|
304
|
+
left: values[1]
|
|
305
|
+
};
|
|
306
|
+
default: return {
|
|
307
|
+
top: values[0],
|
|
308
|
+
right: values[1],
|
|
309
|
+
bottom: values[2],
|
|
310
|
+
left: values[3]
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Strips quotes and returns the first font in a font-family stack.
|
|
316
|
+
*/
|
|
317
|
+
function parseFontFamily(value) {
|
|
318
|
+
if (!value) return "";
|
|
319
|
+
return value.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Parses an alignment to one of the three the block model accepts.
|
|
323
|
+
*/
|
|
324
|
+
function parseAlignment(value, fallback = "left") {
|
|
325
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
326
|
+
if (v === "left" || v === "center" || v === "right") return v;
|
|
327
|
+
return fallback;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Reads a percentage value, or `null` when the value is not a percentage.
|
|
331
|
+
*
|
|
332
|
+
* `null` rather than a number, because column-width matching (§8.1) has to tell
|
|
333
|
+
* "no percentage given" from "0%" — the former distributes widths equally, the
|
|
334
|
+
* latter is a real (if degenerate) width.
|
|
335
|
+
*/
|
|
336
|
+
function parsePercent(value) {
|
|
337
|
+
if (!value) return null;
|
|
338
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*%$/);
|
|
339
|
+
return match ? parseFloat(match[1]) : null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Reads a definite px length, or `null` when the value is not one.
|
|
343
|
+
*
|
|
344
|
+
* Unlike `parsePxValue`, which returns `0` for anything unparseable, this
|
|
345
|
+
* tells "no length given" apart from "0px" — column-width recovery (§8.1)
|
|
346
|
+
* needs that distinction for a px `mj-column` width the same way it needs
|
|
347
|
+
* `parsePercent`'s `null` for a percentage one, so the two compose into a
|
|
348
|
+
* single known-or-absent value the matcher can fill around.
|
|
349
|
+
*/
|
|
350
|
+
function parseDefinitePx(value) {
|
|
351
|
+
if (!value) return null;
|
|
352
|
+
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)\s*(?:px)?$/);
|
|
353
|
+
return match ? parseFloat(match[1]) : null;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Narrows a border style to the three `DividerBlock.lineStyle` accepts.
|
|
357
|
+
*/
|
|
358
|
+
function parseBorderStyle(value) {
|
|
359
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
360
|
+
if (v === "dashed" || v === "dotted") return v;
|
|
361
|
+
return "solid";
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/block-base.ts
|
|
365
|
+
/**
|
|
366
|
+
* The `styles` and `visibility` every block shares.
|
|
367
|
+
*
|
|
368
|
+
* `placement` mirrors the renderer's own `BgPlacement` split
|
|
369
|
+
* (`packages/renderer/src/utils.ts`): `mj-section` is the one "native"
|
|
370
|
+
* element, whose own `background-color` attribute *is* `styles.backgroundColor`
|
|
371
|
+
* (`renderers/section.ts:32`). Every other block type emits
|
|
372
|
+
* `container-background-color` for that same field, because a plain
|
|
373
|
+
* `background-color` on those tags already means something else of the
|
|
374
|
+
* element's own — a button's fill (`renderers/button.ts:43`), for one — and
|
|
375
|
+
* reading it back as the block's container background would invent a fill
|
|
376
|
+
* the source never had. Defaulting to `"container"` matches every caller but
|
|
377
|
+
* `buildSection`.
|
|
378
|
+
*
|
|
379
|
+
* `visibility` is spread conditionally so an unset block carries no key — the
|
|
380
|
+
* block model treats absence as "visible everywhere".
|
|
381
|
+
*/
|
|
382
|
+
function baseFields(attrs, placement = "container") {
|
|
383
|
+
const backgroundColor = parseColor(attrs[placement === "native" ? "background-color" : "container-background-color"]);
|
|
384
|
+
const visibility = readVisibility(attrs);
|
|
385
|
+
return {
|
|
386
|
+
styles: {
|
|
387
|
+
padding: parsePaddingShorthand(attrs.padding),
|
|
388
|
+
...backgroundColor ? { backgroundColor } : {}
|
|
389
|
+
},
|
|
390
|
+
...visibility ? { visibility } : {}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function warnForeignClasses(attrs, tag, ctx) {
|
|
394
|
+
for (const name of readForeignCssClasses(attrs)) ctx.warnings.push(`Dropped CSS class "${name}" on <${tag}> — consumer CSS has no Templatical equivalent.`);
|
|
395
|
+
}
|
|
396
|
+
function isNewTab(attrs) {
|
|
397
|
+
return (attrs.target ?? "").trim().toLowerCase() === "_blank";
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Wrap the element's own markup in an HTML block — the lossless fallback.
|
|
401
|
+
*/
|
|
402
|
+
function convertHtmlFallback($el, ctx, attrs) {
|
|
403
|
+
const outer = ctx.$.html($el) ?? "";
|
|
404
|
+
return createHtmlBlock({
|
|
405
|
+
content: outer,
|
|
406
|
+
...baseFields(attrs)
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/composite-mapper.ts
|
|
411
|
+
/**
|
|
412
|
+
* Exhaustive over `SocialPlatform` on purpose: adding a member to that union
|
|
413
|
+
* without adding it here is a compile error, so the importer cannot silently
|
|
414
|
+
* fall back to "website" for a platform the block model gained.
|
|
415
|
+
*/
|
|
416
|
+
const KNOWN_PLATFORMS = {
|
|
417
|
+
facebook: true,
|
|
418
|
+
twitter: true,
|
|
419
|
+
instagram: true,
|
|
420
|
+
linkedin: true,
|
|
421
|
+
youtube: true,
|
|
422
|
+
tiktok: true,
|
|
423
|
+
pinterest: true,
|
|
424
|
+
email: true,
|
|
425
|
+
whatsapp: true,
|
|
426
|
+
telegram: true,
|
|
427
|
+
discord: true,
|
|
428
|
+
snapchat: true,
|
|
429
|
+
reddit: true,
|
|
430
|
+
github: true,
|
|
431
|
+
dribbble: true,
|
|
432
|
+
behance: true,
|
|
433
|
+
website: true
|
|
434
|
+
};
|
|
435
|
+
const PLATFORM_ALIASES = {
|
|
436
|
+
x: "twitter",
|
|
437
|
+
"x-twitter": "twitter"
|
|
438
|
+
};
|
|
439
|
+
function normalizePlatform(raw) {
|
|
440
|
+
const cleaned = raw.trim().toLowerCase().replace(/-noshare$/, "");
|
|
441
|
+
if (!cleaned) return null;
|
|
442
|
+
if (PLATFORM_ALIASES[cleaned]) return PLATFORM_ALIASES[cleaned];
|
|
443
|
+
return cleaned in KNOWN_PLATFORMS ? cleaned : null;
|
|
444
|
+
}
|
|
445
|
+
/** The `<style>/<platform>.png` tail of the URL `renderers/social.ts:76` builds. */
|
|
446
|
+
function platformFromSrc(src) {
|
|
447
|
+
const parts = src.split("?")[0].split("/").filter(Boolean);
|
|
448
|
+
return {
|
|
449
|
+
platform: (parts.at(-1) ?? "").replace(/\.[a-z0-9]+$/i, ""),
|
|
450
|
+
style: parts.at(-2) ?? ""
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const ICON_SIZES = [
|
|
454
|
+
[24, "small"],
|
|
455
|
+
[32, "medium"],
|
|
456
|
+
[48, "large"]
|
|
457
|
+
];
|
|
458
|
+
function nearestIconSize(px) {
|
|
459
|
+
let best = ICON_SIZES[1];
|
|
460
|
+
let bestGap = Infinity;
|
|
461
|
+
for (const candidate of ICON_SIZES) {
|
|
462
|
+
const gap = Math.abs(candidate[0] - px);
|
|
463
|
+
if (gap < bestGap) {
|
|
464
|
+
bestGap = gap;
|
|
465
|
+
best = candidate;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
size: best[1],
|
|
470
|
+
exact: bestGap === 0
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
const RADIUS_STYLES = {
|
|
474
|
+
"50%": "circle",
|
|
475
|
+
"8px": "rounded",
|
|
476
|
+
"0": "square",
|
|
477
|
+
"4px": "solid"
|
|
478
|
+
};
|
|
479
|
+
const KNOWN_ICON_STYLES = /* @__PURE__ */ new Set([
|
|
480
|
+
"solid",
|
|
481
|
+
"outlined",
|
|
482
|
+
"rounded",
|
|
483
|
+
"square",
|
|
484
|
+
"circle"
|
|
485
|
+
]);
|
|
486
|
+
function convertSocial($el, attrs, ctx) {
|
|
487
|
+
const elements = childElements($el, ctx.$).filter(($child) => tagOf($child[0]) === "mj-social-element");
|
|
488
|
+
if (elements.length === 0) return null;
|
|
489
|
+
const notes = [];
|
|
490
|
+
const icons = [];
|
|
491
|
+
let iconStyle = null;
|
|
492
|
+
let iconSizePx = 0;
|
|
493
|
+
let spacing = 0;
|
|
494
|
+
elements.forEach(($child, index) => {
|
|
495
|
+
const childAttrs = resolveAttributes($child, ctx.cascade);
|
|
496
|
+
const src = (childAttrs.src ?? "").trim();
|
|
497
|
+
const fromSrc = src ? platformFromSrc(src) : {
|
|
498
|
+
platform: "",
|
|
499
|
+
style: ""
|
|
500
|
+
};
|
|
501
|
+
const rawName = (childAttrs.name ?? "").trim() || fromSrc.platform;
|
|
502
|
+
const platform = normalizePlatform(rawName);
|
|
503
|
+
if (!platform && rawName) notes.push(`Unrecognised social platform "${rawName}" mapped to "website".`);
|
|
504
|
+
icons.push({
|
|
505
|
+
id: generateId(),
|
|
506
|
+
platform: platform ?? "website",
|
|
507
|
+
url: (childAttrs.href ?? "").trim()
|
|
508
|
+
});
|
|
509
|
+
if (!iconStyle && KNOWN_ICON_STYLES.has(fromSrc.style)) iconStyle = fromSrc.style;
|
|
510
|
+
if (iconSizePx === 0) iconSizePx = parsePxValue(childAttrs["icon-size"]);
|
|
511
|
+
if (spacing === 0 && index < elements.length - 1) spacing = parsePxValue((childAttrs.padding ?? "").trim().split(/\s+/)[1]);
|
|
512
|
+
if (!iconStyle) {
|
|
513
|
+
const radius = (childAttrs["border-radius"] ?? "").trim().toLowerCase();
|
|
514
|
+
const mapped = RADIUS_STYLES[radius];
|
|
515
|
+
if (mapped) {
|
|
516
|
+
iconStyle = mapped;
|
|
517
|
+
if (radius === "4px") notes.push("Icon border-radius 4px maps to both \"solid\" and \"outlined\"; resolved to \"solid\".");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
const declaredSize = parsePxValue(attrs["icon-size"]) || iconSizePx;
|
|
522
|
+
let iconSize;
|
|
523
|
+
if (declaredSize > 0) {
|
|
524
|
+
const resolved = nearestIconSize(declaredSize);
|
|
525
|
+
iconSize = resolved.size;
|
|
526
|
+
if (!resolved.exact) notes.push(`Icon size ${declaredSize}px is not one of 24/32/48; resolved to "${resolved.size}".`);
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
block: createSocialIconsBlock({
|
|
530
|
+
icons,
|
|
531
|
+
align: parseAlignment(attrs.align, "center"),
|
|
532
|
+
...iconSize ? { iconSize } : {},
|
|
533
|
+
...iconStyle ? { iconStyle } : {},
|
|
534
|
+
...spacing > 0 ? { spacing } : {},
|
|
535
|
+
...baseFields(attrs)
|
|
536
|
+
}),
|
|
537
|
+
entry: {
|
|
538
|
+
sourceTag: "mj-social",
|
|
539
|
+
templaticalBlockType: "social",
|
|
540
|
+
status: notes.length > 0 ? "approximated" : "converted",
|
|
541
|
+
...notes.length > 0 ? { note: notes.join(" ") } : {}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
function convertNavbar($el, attrs, ctx) {
|
|
546
|
+
const links = childElements($el, ctx.$).filter(($child) => tagOf($child[0]) === "mj-navbar-link");
|
|
547
|
+
if (links.length === 0) return null;
|
|
548
|
+
const items = links.map(($link) => {
|
|
549
|
+
const linkAttrs = resolveAttributes($link, ctx.cascade);
|
|
550
|
+
const color = parseColor(ownAttr(linkAttrs, "color", "mj-navbar-link", ctx.cascade));
|
|
551
|
+
return {
|
|
552
|
+
id: generateId(),
|
|
553
|
+
text: ($link.text() ?? "").trim(),
|
|
554
|
+
url: (linkAttrs.href ?? "").trim(),
|
|
555
|
+
openInNewTab: (linkAttrs.target ?? "").toLowerCase() === "_blank",
|
|
556
|
+
bold: (linkAttrs["font-weight"] ?? "").toLowerCase() === "bold",
|
|
557
|
+
underline: (linkAttrs["text-decoration"] ?? "").includes("underline"),
|
|
558
|
+
...color ? { color } : {}
|
|
559
|
+
};
|
|
560
|
+
});
|
|
561
|
+
const fontSize = parsePxValue(attrs["font-size"]);
|
|
562
|
+
const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-navbar", ctx.cascade));
|
|
563
|
+
return {
|
|
564
|
+
block: createMenuBlock({
|
|
565
|
+
items,
|
|
566
|
+
textAlign: parseAlignment(attrs.align, "center"),
|
|
567
|
+
...fontSize > 0 ? { fontSize } : {},
|
|
568
|
+
...fontFamily ? { fontFamily } : {},
|
|
569
|
+
...baseFields(attrs)
|
|
570
|
+
}),
|
|
571
|
+
entry: {
|
|
572
|
+
sourceTag: "mj-navbar",
|
|
573
|
+
templaticalBlockType: "menu",
|
|
574
|
+
status: "converted"
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function convertNativeTable($el, attrs, ctx) {
|
|
579
|
+
const $ = ctx.$;
|
|
580
|
+
const rowEls = $el.find("tr").toArray().filter((tr) => $(tr).parentsUntil($el, "table").length === 0);
|
|
581
|
+
if (rowEls.length === 0) return null;
|
|
582
|
+
const rows = rowEls.map((rowEl) => ({
|
|
583
|
+
id: generateId(),
|
|
584
|
+
cells: $(rowEl).children().toArray().map((cellEl) => ({
|
|
585
|
+
id: generateId(),
|
|
586
|
+
content: $(cellEl).html() ?? ""
|
|
587
|
+
}))
|
|
588
|
+
}));
|
|
589
|
+
const hasHeaderRow = $(rowEls[0]).children().toArray().some((cell) => tagOf(cell) === "th");
|
|
590
|
+
const color = parseColor(ownAttr(attrs, "color", "mj-table", ctx.cascade));
|
|
591
|
+
const fontSize = parsePxValue(attrs["font-size"]);
|
|
592
|
+
const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-table", ctx.cascade));
|
|
593
|
+
return {
|
|
594
|
+
block: createTableBlock({
|
|
595
|
+
rows,
|
|
596
|
+
hasHeaderRow,
|
|
597
|
+
textAlign: parseAlignment(attrs.align, "left"),
|
|
598
|
+
...color ? { color } : {},
|
|
599
|
+
...fontSize > 0 ? { fontSize } : {},
|
|
600
|
+
...fontFamily ? { fontFamily } : {},
|
|
601
|
+
...baseFields(attrs)
|
|
602
|
+
}),
|
|
603
|
+
entry: {
|
|
604
|
+
sourceTag: "mj-table",
|
|
605
|
+
templaticalBlockType: "table",
|
|
606
|
+
status: "converted"
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/text-inference.ts
|
|
612
|
+
/**
|
|
613
|
+
* Re-parse an `mj-text`'s inner markup into its own document.
|
|
614
|
+
*
|
|
615
|
+
* The surrounding document already parses void elements correctly (`br`,
|
|
616
|
+
* `img`, … stay void under `converter.ts`'s `xmlMode: false`), so this reparse
|
|
617
|
+
* isn't compensating for a different parser here. It gives each
|
|
618
|
+
* shape-detection helper (title / table / menu) an isolated, freshly
|
|
619
|
+
* queryable document scoped to just this block's own markup — so
|
|
620
|
+
* `$inner("tr")` can only match rows that belong to this table, and
|
|
621
|
+
* `$inner("body")` has a real root to enumerate top-level nodes from. A
|
|
622
|
+
* paragraph's markup needs none of that structure-probing, so it is passed
|
|
623
|
+
* through verbatim and never reparsed.
|
|
624
|
+
*/
|
|
625
|
+
function parseInner(html) {
|
|
626
|
+
return load(`<body>${html}</body>`);
|
|
627
|
+
}
|
|
628
|
+
function rootElements(html) {
|
|
629
|
+
const kids = parseInner(html)("body").children().toArray();
|
|
630
|
+
return {
|
|
631
|
+
tag: kids.length > 0 ? kids[0].tagName?.toLowerCase() ?? "" : "",
|
|
632
|
+
count: kids.length
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const HEADING_LEVELS = {
|
|
636
|
+
h1: 1,
|
|
637
|
+
h2: 2,
|
|
638
|
+
h3: 3,
|
|
639
|
+
h4: 4,
|
|
640
|
+
h5: 5,
|
|
641
|
+
h6: 6
|
|
642
|
+
};
|
|
643
|
+
function convertTitle(html, attrs, sourceLevel, cascade) {
|
|
644
|
+
const $heading = parseInner(html)("body").children().first();
|
|
645
|
+
const level = Math.min(sourceLevel, 4);
|
|
646
|
+
const color = parseColor(ownAttr(attrs, "color", "mj-text", cascade));
|
|
647
|
+
const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-text", cascade));
|
|
648
|
+
const block = createTitleBlock({
|
|
649
|
+
content: $heading.html() ?? "",
|
|
650
|
+
level,
|
|
651
|
+
textAlign: parseAlignment(attrs.align, "left"),
|
|
652
|
+
...color ? { color } : {},
|
|
653
|
+
...fontFamily ? { fontFamily } : {},
|
|
654
|
+
...baseFields(attrs)
|
|
655
|
+
});
|
|
656
|
+
const clamped = sourceLevel > 4;
|
|
657
|
+
return {
|
|
658
|
+
block,
|
|
659
|
+
entry: {
|
|
660
|
+
sourceTag: "mj-text",
|
|
661
|
+
templaticalBlockType: "title",
|
|
662
|
+
status: clamped ? "approximated" : "converted",
|
|
663
|
+
...clamped ? { note: `Heading level h${sourceLevel} clamped to 4 — Templatical titles support h1-h4.` } : {}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function convertTable(html, attrs, cascade) {
|
|
668
|
+
const $inner = parseInner(html);
|
|
669
|
+
const $rows = $inner("tr");
|
|
670
|
+
const rows = $rows.toArray().map((rowEl) => ({
|
|
671
|
+
id: generateId(),
|
|
672
|
+
cells: $inner(rowEl).children().toArray().map((cellEl) => ({
|
|
673
|
+
id: generateId(),
|
|
674
|
+
content: $inner(cellEl).html() ?? ""
|
|
675
|
+
}))
|
|
676
|
+
}));
|
|
677
|
+
const hasHeaderRow = $rows.length > 0 && $inner($rows[0]).children("th").length > 0;
|
|
678
|
+
const color = parseColor(ownAttr(attrs, "color", "mj-text", cascade));
|
|
679
|
+
const fontSize = parsePxValue(attrs["font-size"]);
|
|
680
|
+
const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-text", cascade));
|
|
681
|
+
return {
|
|
682
|
+
block: createTableBlock({
|
|
683
|
+
rows,
|
|
684
|
+
hasHeaderRow,
|
|
685
|
+
textAlign: parseAlignment(attrs.align, "left"),
|
|
686
|
+
...color ? { color } : {},
|
|
687
|
+
...fontSize > 0 ? { fontSize } : {},
|
|
688
|
+
...fontFamily ? { fontFamily } : {},
|
|
689
|
+
...baseFields(attrs)
|
|
690
|
+
}),
|
|
691
|
+
entry: {
|
|
692
|
+
sourceTag: "mj-text",
|
|
693
|
+
templaticalBlockType: "table",
|
|
694
|
+
status: "converted"
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* A menu is top-level anchors with optional `<span>` separators between them —
|
|
700
|
+
* exactly what `renderers/menu.ts` emits, and deliberately not matched when a
|
|
701
|
+
* `<p>` wrapper is present (that is a paragraph containing links).
|
|
702
|
+
*/
|
|
703
|
+
function looksLikeMenu(html) {
|
|
704
|
+
const kids = parseInner(html)("body").children().toArray();
|
|
705
|
+
if (kids.length === 0) return false;
|
|
706
|
+
let anchors = 0;
|
|
707
|
+
for (const kid of kids) {
|
|
708
|
+
const tag = kid.tagName?.toLowerCase() ?? "";
|
|
709
|
+
if (tag === "a") anchors += 1;
|
|
710
|
+
else if (tag !== "span") return false;
|
|
711
|
+
}
|
|
712
|
+
return anchors > 0;
|
|
713
|
+
}
|
|
714
|
+
function convertMenu(html, attrs, cascade) {
|
|
715
|
+
const $inner = parseInner(html);
|
|
716
|
+
const items = $inner("body").children("a").toArray().map((el) => {
|
|
717
|
+
const $a = $inner(el);
|
|
718
|
+
const itemColor = parseColor($a.attr("style")?.match(/color\s*:\s*([^;]+)/i)?.[1]);
|
|
719
|
+
return {
|
|
720
|
+
id: generateId(),
|
|
721
|
+
text: ($a.text() ?? "").trim(),
|
|
722
|
+
url: $a.attr("href") ?? "",
|
|
723
|
+
openInNewTab: ($a.attr("target") ?? "").toLowerCase() === "_blank",
|
|
724
|
+
bold: $a.find("strong, b").length > 0,
|
|
725
|
+
underline: ($a.attr("style") ?? "").includes("underline"),
|
|
726
|
+
...itemColor ? { color: itemColor } : {}
|
|
727
|
+
};
|
|
728
|
+
});
|
|
729
|
+
const $separator = $inner("body").children("span").first();
|
|
730
|
+
const separator = ($separator.text() ?? "").trim();
|
|
731
|
+
const separatorColor = parseColor($separator.attr("style")?.match(/color\s*:\s*([^;]+)/i)?.[1]);
|
|
732
|
+
const spacing = parsePxValue($separator.attr("style")?.match(/padding\s*:\s*0\s+([\d.]+px)/i)?.[1]);
|
|
733
|
+
const color = parseColor(ownAttr(attrs, "color", "mj-text", cascade));
|
|
734
|
+
const fontSize = parsePxValue(attrs["font-size"]);
|
|
735
|
+
const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-text", cascade));
|
|
736
|
+
return {
|
|
737
|
+
block: createMenuBlock({
|
|
738
|
+
items,
|
|
739
|
+
textAlign: parseAlignment(attrs.align, "center"),
|
|
740
|
+
...separator ? { separator } : {},
|
|
741
|
+
...separatorColor ? { separatorColor } : {},
|
|
742
|
+
...spacing > 0 ? { spacing } : {},
|
|
743
|
+
...color ? { color } : {},
|
|
744
|
+
...fontSize > 0 ? { fontSize } : {},
|
|
745
|
+
...fontFamily ? { fontFamily } : {},
|
|
746
|
+
...baseFields(attrs)
|
|
747
|
+
}),
|
|
748
|
+
entry: {
|
|
749
|
+
sourceTag: "mj-text",
|
|
750
|
+
templaticalBlockType: "menu",
|
|
751
|
+
status: "converted"
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* The editor's rich-text blocks assume a block-level wrapper, so bare text gets
|
|
757
|
+
* one. Mirrors `ensureParagraphWrapped` in `@templatical/import-html`.
|
|
758
|
+
*/
|
|
759
|
+
function ensureParagraphWrapped(html) {
|
|
760
|
+
const trimmed = html.trim();
|
|
761
|
+
if (trimmed === "") return "<p></p>";
|
|
762
|
+
if (/^<(p|h[1-6]|ul|ol|blockquote|div)\b/i.test(trimmed)) return trimmed;
|
|
763
|
+
return `<p>${trimmed}</p>`;
|
|
764
|
+
}
|
|
765
|
+
function convertParagraph(html, attrs) {
|
|
766
|
+
const gap = readParagraphGap(attrs);
|
|
767
|
+
const paragraphSpacing = gap !== null && gap !== RICH_TEXT_SPACING.paragraphGap ? gap : void 0;
|
|
768
|
+
return {
|
|
769
|
+
block: createParagraphBlock({
|
|
770
|
+
content: ensureParagraphWrapped(html),
|
|
771
|
+
...paragraphSpacing !== void 0 ? { paragraphSpacing } : {},
|
|
772
|
+
...baseFields(attrs)
|
|
773
|
+
}),
|
|
774
|
+
entry: {
|
|
775
|
+
sourceTag: "mj-text",
|
|
776
|
+
templaticalBlockType: "paragraph",
|
|
777
|
+
status: "converted"
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Resolve an `mj-text` to Title, Table, Menu or Paragraph by the shape of its
|
|
783
|
+
* content — the reverse of the four renderers that all emit `mj-text`.
|
|
784
|
+
*
|
|
785
|
+
* A fifth renderer emits it too: `HtmlBlock` (`renderers/html.ts`), whose
|
|
786
|
+
* content is arbitrary, so it has no shape to match and lands on the Paragraph
|
|
787
|
+
* fallback. That is irreducible — nothing in the output marks a block's type —
|
|
788
|
+
* and narrowing the Title/Table shapes to compensate would break the common
|
|
789
|
+
* case to serve the rare one. See §10 of the design.
|
|
790
|
+
*
|
|
791
|
+
* Paragraph is the terminal arm and always reachable, so this is total.
|
|
792
|
+
*/
|
|
793
|
+
function convertTextElement($el, attrs, ctx) {
|
|
794
|
+
const html = $el.html() ?? "";
|
|
795
|
+
const root = rootElements(html);
|
|
796
|
+
if (root.count === 1 && HEADING_LEVELS[root.tag]) return convertTitle(html, attrs, HEADING_LEVELS[root.tag], ctx.cascade);
|
|
797
|
+
if (root.count === 1 && root.tag === "table") return convertTable(html, attrs, ctx.cascade);
|
|
798
|
+
if (looksLikeMenu(html)) return convertMenu(html, attrs, ctx.cascade);
|
|
799
|
+
return convertParagraph(html, attrs);
|
|
800
|
+
}
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/block-mapper.ts
|
|
803
|
+
/** Tags handled elsewhere but recognised, so they never hit the unknown-tag arm. */
|
|
804
|
+
const STRUCTURAL_TAGS = /* @__PURE__ */ new Set([
|
|
805
|
+
"mjml",
|
|
806
|
+
"mj-head",
|
|
807
|
+
"mj-body",
|
|
808
|
+
"mj-wrapper",
|
|
809
|
+
"mj-section",
|
|
810
|
+
"mj-column",
|
|
811
|
+
"mj-group",
|
|
812
|
+
"mj-attributes",
|
|
813
|
+
"mj-all",
|
|
814
|
+
"mj-class",
|
|
815
|
+
"mj-font",
|
|
816
|
+
"mj-style",
|
|
817
|
+
"mj-title",
|
|
818
|
+
"mj-preview",
|
|
819
|
+
"mj-breakpoint",
|
|
820
|
+
"mj-html-attributes",
|
|
821
|
+
"mj-social-element",
|
|
822
|
+
"mj-navbar-link"
|
|
823
|
+
]);
|
|
824
|
+
/** Tags with no Templatical equivalent that keep their markup verbatim. */
|
|
825
|
+
const NO_EQUIVALENT_TAGS = /* @__PURE__ */ new Set([
|
|
826
|
+
"mj-hero",
|
|
827
|
+
"mj-carousel",
|
|
828
|
+
"mj-accordion"
|
|
829
|
+
]);
|
|
830
|
+
function convertImage($el, attrs, ctx) {
|
|
831
|
+
const src = (attrs.src ?? "").trim();
|
|
832
|
+
if (!src) return null;
|
|
833
|
+
const decorative = (attrs.role ?? "").trim().toLowerCase() === "presentation";
|
|
834
|
+
const pxWidth = parsePxValue(attrs.width);
|
|
835
|
+
const height = parsePxValue(attrs.height);
|
|
836
|
+
const borderRadius = parsePxValue(attrs["border-radius"]);
|
|
837
|
+
const href = (attrs.href ?? "").trim();
|
|
838
|
+
return createImageBlock({
|
|
839
|
+
src,
|
|
840
|
+
alt: decorative ? "" : attrs.alt ?? "",
|
|
841
|
+
width: pxWidth === ctx.containerWidth ? "full" : pxWidth || "full",
|
|
842
|
+
align: parseAlignment(attrs.align, "center"),
|
|
843
|
+
...height > 0 ? { height } : {},
|
|
844
|
+
...borderRadius > 0 ? { borderRadius } : {},
|
|
845
|
+
...href ? { linkUrl: href } : {},
|
|
846
|
+
...href && isNewTab(attrs) ? { linkOpenInNewTab: true } : {},
|
|
847
|
+
...decorative ? { decorative: true } : {},
|
|
848
|
+
...baseFields(attrs)
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
function convertButton($el, attrs) {
|
|
852
|
+
const text = ($el.text() ?? "").trim();
|
|
853
|
+
if (!text) return null;
|
|
854
|
+
const backgroundColor = parseColor(attrs["background-color"]);
|
|
855
|
+
const textColor = parseColor(attrs.color);
|
|
856
|
+
const fontSize = parsePxValue(attrs["font-size"]);
|
|
857
|
+
return createButtonBlock({
|
|
858
|
+
text,
|
|
859
|
+
url: (attrs.href ?? "").trim(),
|
|
860
|
+
...backgroundColor ? { backgroundColor } : {},
|
|
861
|
+
...textColor ? { textColor } : {},
|
|
862
|
+
...fontSize > 0 ? { fontSize } : {},
|
|
863
|
+
...attrs["border-radius"] !== void 0 ? { borderRadius: parsePxValue(attrs["border-radius"]) } : {},
|
|
864
|
+
...attrs["inner-padding"] !== void 0 ? { buttonPadding: parsePaddingShorthand(attrs["inner-padding"]) } : {},
|
|
865
|
+
align: parseAlignment(attrs.align, "center"),
|
|
866
|
+
...isNewTab(attrs) ? { openInNewTab: true } : {},
|
|
867
|
+
...baseFields(attrs)
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function convertDivider(attrs) {
|
|
871
|
+
const color = parseColor(attrs["border-color"]);
|
|
872
|
+
const thickness = parsePxValue(attrs["border-width"]);
|
|
873
|
+
return createDividerBlock({
|
|
874
|
+
lineStyle: parseBorderStyle(attrs["border-style"]),
|
|
875
|
+
...color ? { color } : {},
|
|
876
|
+
...attrs["border-width"] !== void 0 ? { thickness } : {},
|
|
877
|
+
...baseFields(attrs)
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
function convertSpacer(attrs) {
|
|
881
|
+
const height = parsePxValue(attrs.height);
|
|
882
|
+
return createSpacerBlock({
|
|
883
|
+
...attrs.height !== void 0 ? { height } : {},
|
|
884
|
+
...baseFields(attrs)
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Convert one MJML element to a Templatical block.
|
|
889
|
+
*
|
|
890
|
+
* Returns `null` for an element that produces nothing *and* warrants no report
|
|
891
|
+
* entry — an image with no `src`, a button with no label. A `Converted` whose
|
|
892
|
+
* `block` is null is a *skip*, which does get an entry.
|
|
893
|
+
*/
|
|
894
|
+
function convertElement($el, ctx) {
|
|
895
|
+
const tag = tagOf($el[0]);
|
|
896
|
+
if (!tag) return null;
|
|
897
|
+
const attrs = resolveAttributes($el, ctx.cascade);
|
|
898
|
+
warnForeignClasses(attrs, tag, ctx);
|
|
899
|
+
if (tag === "mj-include") return {
|
|
900
|
+
block: null,
|
|
901
|
+
entry: {
|
|
902
|
+
sourceTag: tag,
|
|
903
|
+
templaticalBlockType: null,
|
|
904
|
+
status: "skipped",
|
|
905
|
+
note: `Cannot resolve <mj-include path="${(attrs.path ?? "").trim()}"> — the importer reads a single string and has no filesystem access. Inline the include before importing.`
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
if (tag === "mj-text") return convertTextElement($el, attrs, ctx);
|
|
909
|
+
if (tag === "mj-social") return convertSocial($el, attrs, ctx);
|
|
910
|
+
if (tag === "mj-navbar") return convertNavbar($el, attrs, ctx);
|
|
911
|
+
if (tag === "mj-table") return convertNativeTable($el, attrs, ctx);
|
|
912
|
+
if (tag === "mj-image") {
|
|
913
|
+
const block = convertImage($el, attrs, ctx);
|
|
914
|
+
if (!block) return null;
|
|
915
|
+
return {
|
|
916
|
+
block,
|
|
917
|
+
entry: {
|
|
918
|
+
sourceTag: tag,
|
|
919
|
+
templaticalBlockType: "image",
|
|
920
|
+
status: "converted"
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
if (tag === "mj-button") {
|
|
925
|
+
const block = convertButton($el, attrs);
|
|
926
|
+
if (!block) return null;
|
|
927
|
+
return {
|
|
928
|
+
block,
|
|
929
|
+
entry: {
|
|
930
|
+
sourceTag: tag,
|
|
931
|
+
templaticalBlockType: "button",
|
|
932
|
+
status: "converted"
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
if (tag === "mj-divider") return {
|
|
937
|
+
block: convertDivider(attrs),
|
|
938
|
+
entry: {
|
|
939
|
+
sourceTag: tag,
|
|
940
|
+
templaticalBlockType: "divider",
|
|
941
|
+
status: "converted"
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
if (tag === "mj-spacer") return {
|
|
945
|
+
block: convertSpacer(attrs),
|
|
946
|
+
entry: {
|
|
947
|
+
sourceTag: tag,
|
|
948
|
+
templaticalBlockType: "spacer",
|
|
949
|
+
status: "converted"
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
if (tag === "mj-raw") return {
|
|
953
|
+
block: createHtmlBlock({
|
|
954
|
+
content: $el.html() ?? "",
|
|
955
|
+
...baseFields(attrs)
|
|
956
|
+
}),
|
|
957
|
+
entry: {
|
|
958
|
+
sourceTag: tag,
|
|
959
|
+
templaticalBlockType: "html",
|
|
960
|
+
status: "converted"
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
if (NO_EQUIVALENT_TAGS.has(tag)) return {
|
|
964
|
+
block: convertHtmlFallback($el, ctx, attrs),
|
|
965
|
+
entry: {
|
|
966
|
+
sourceTag: tag,
|
|
967
|
+
templaticalBlockType: "html",
|
|
968
|
+
status: "html-fallback",
|
|
969
|
+
note: `<${tag}> has no Templatical block equivalent; the original markup is preserved.`
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
if (STRUCTURAL_TAGS.has(tag)) return null;
|
|
973
|
+
return {
|
|
974
|
+
block: convertHtmlFallback($el, ctx, attrs),
|
|
975
|
+
entry: {
|
|
976
|
+
sourceTag: tag,
|
|
977
|
+
templaticalBlockType: "html",
|
|
978
|
+
status: "html-fallback",
|
|
979
|
+
note: `<${tag}> is not a known MJML element (a custom component?); the original markup is preserved.`
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
//#endregion
|
|
984
|
+
//#region src/display-condition.ts
|
|
985
|
+
/** Characters kept before an ellipsis is appended; the full condition stays in `before`. */
|
|
986
|
+
const LABEL_MAX = 46;
|
|
987
|
+
const ANCHORED_LOGIC = Object.values(SYNTAX_PRESETS).map((preset) => new RegExp(`^(?:${preset.logic.source})$`, preset.logic.flags.replace("g", "")));
|
|
988
|
+
/**
|
|
989
|
+
* Whether the text is exactly one logic tag and nothing else.
|
|
990
|
+
*
|
|
991
|
+
* Anchored against every registered syntax rather than one, so a mailchimp or
|
|
992
|
+
* ampscript template is recognised as readily as a liquid one. MSO conditionals
|
|
993
|
+
* are HTML comments and match none of them, which is what keeps hand-written
|
|
994
|
+
* `mj-raw` pairs out of this path.
|
|
995
|
+
*/
|
|
996
|
+
function isLogicTagOnly(text) {
|
|
997
|
+
const trimmed = text.trim();
|
|
998
|
+
if (trimmed === "") return false;
|
|
999
|
+
return ANCHORED_LOGIC.some((regex) => regex.test(trimmed));
|
|
1000
|
+
}
|
|
1001
|
+
function isLogicRaw($el) {
|
|
1002
|
+
return tagOf($el[0]) === "mj-raw" && isLogicTagOnly($el.text() ?? "");
|
|
1003
|
+
}
|
|
1004
|
+
function synthesizeLabel(before) {
|
|
1005
|
+
const trimmed = before.trim();
|
|
1006
|
+
if (trimmed.length <= LABEL_MAX) return trimmed;
|
|
1007
|
+
return `${trimmed.slice(0, LABEL_MAX)}…`;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Group a run of siblings into units, folding each `logic-raw / element /
|
|
1011
|
+
* logic-raw` triple into one unit carrying a `displayCondition`.
|
|
1012
|
+
*
|
|
1013
|
+
* `label` is synthesised from `before`: it is editor metadata that appears
|
|
1014
|
+
* nowhere in the MJML, so it cannot be recovered — only reconstructed. `group`
|
|
1015
|
+
* and `description` are left absent for the same reason.
|
|
1016
|
+
*
|
|
1017
|
+
* Deliberately no check that the two tags pair *semantically*. The renderer
|
|
1018
|
+
* emits them only in this arrangement, and matching open/close keywords across
|
|
1019
|
+
* four syntaxes would be a table to maintain for no gain.
|
|
1020
|
+
*/
|
|
1021
|
+
function planSiblings($siblings) {
|
|
1022
|
+
const units = [];
|
|
1023
|
+
let i = 0;
|
|
1024
|
+
while (i < $siblings.length) {
|
|
1025
|
+
const $current = $siblings[i];
|
|
1026
|
+
const $middle = $siblings[i + 1];
|
|
1027
|
+
const $closing = $siblings[i + 2];
|
|
1028
|
+
if (isLogicRaw($current) && $middle !== void 0 && tagOf($middle[0]) !== "mj-raw" && $closing !== void 0 && isLogicRaw($closing)) {
|
|
1029
|
+
const before = ($current.text() ?? "").trim();
|
|
1030
|
+
const after = ($closing.text() ?? "").trim();
|
|
1031
|
+
units.push({
|
|
1032
|
+
$el: $middle,
|
|
1033
|
+
displayCondition: {
|
|
1034
|
+
label: synthesizeLabel(before),
|
|
1035
|
+
before,
|
|
1036
|
+
after
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
i += 3;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
units.push({ $el: $current });
|
|
1043
|
+
i += 1;
|
|
1044
|
+
}
|
|
1045
|
+
return units;
|
|
1046
|
+
}
|
|
1047
|
+
//#endregion
|
|
1048
|
+
//#region src/head-parser.ts
|
|
1049
|
+
/** `mj-head` children this module reads; anything else is warned about. */
|
|
1050
|
+
const CONSUMED_HEAD_TAGS = /* @__PURE__ */ new Set([
|
|
1051
|
+
"mj-attributes",
|
|
1052
|
+
"mj-preview",
|
|
1053
|
+
"mj-font",
|
|
1054
|
+
"mj-style",
|
|
1055
|
+
"mj-title"
|
|
1056
|
+
]);
|
|
1057
|
+
/**
|
|
1058
|
+
* `DEFAULT_TEMPLATE_DEFAULTS` is typed `Partial<TemplateSettings>` so a
|
|
1059
|
+
* consumer can override any subset of it, but its own literal (see
|
|
1060
|
+
* `packages/types/src/defaults.ts`) always sets exactly the six required
|
|
1061
|
+
* `TemplateSettings` fields — `linkColor` and `preheaderText` are the two
|
|
1062
|
+
* optional ones and are correctly absent from it. Narrowing the type once
|
|
1063
|
+
* here, rather than at every read below, is what lets `width`, `fontFamily`
|
|
1064
|
+
* and the rest come out as `number`/`string`/`boolean` instead of `| undefined`.
|
|
1065
|
+
*/
|
|
1066
|
+
const REQUIRED_TEMPLATE_DEFAULTS = DEFAULT_TEMPLATE_DEFAULTS;
|
|
1067
|
+
/**
|
|
1068
|
+
* Read the `a { … }` declarations out of the concatenated `mj-style` blocks.
|
|
1069
|
+
*
|
|
1070
|
+
* This is the reverse of how the renderer emits `settings.linkColor` and
|
|
1071
|
+
* `settings.linkUnderline` — as a global anchor rule — so a template that
|
|
1072
|
+
* round-trips keeps both. A stylesheet with no anchor rule leaves both unset.
|
|
1073
|
+
*/
|
|
1074
|
+
function readAnchorRule(css) {
|
|
1075
|
+
const rule = {};
|
|
1076
|
+
const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
1077
|
+
for (const match of cssWithoutComments.matchAll(/(^|[},])\s*a\s*\{([^}]*)\}/g)) {
|
|
1078
|
+
const body = match[2];
|
|
1079
|
+
const color = body.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
|
|
1080
|
+
if (color) {
|
|
1081
|
+
const parsed = parseColor(color[1]);
|
|
1082
|
+
if (parsed) rule.color = parsed;
|
|
1083
|
+
}
|
|
1084
|
+
const decoration = body.match(/(?:^|;)\s*text-decoration\s*:\s*([^;]+)/i);
|
|
1085
|
+
if (decoration) rule.underline = decoration[1].trim().toLowerCase().includes("underline");
|
|
1086
|
+
}
|
|
1087
|
+
return rule;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Build `TemplateSettings` from `mj-body`'s attributes, the attribute cascade
|
|
1091
|
+
* and the remaining `mj-head` children.
|
|
1092
|
+
*
|
|
1093
|
+
* Optional keys (`preheaderText`, `linkColor`) are **omitted** rather than set
|
|
1094
|
+
* to `undefined`: an absent key is what the block model means by unset, and a
|
|
1095
|
+
* present-but-undefined key serialises into exported JSON.
|
|
1096
|
+
*/
|
|
1097
|
+
function extractSettings($, cascade, warnings) {
|
|
1098
|
+
const $body = findByTag($, "mj-body").first();
|
|
1099
|
+
const $root = findByTag($, "mjml").first();
|
|
1100
|
+
const width = parsePxValue($body.attr("width")) || REQUIRED_TEMPLATE_DEFAULTS.width;
|
|
1101
|
+
const backgroundColor = parseColor($body.attr("background-color")) || REQUIRED_TEMPLATE_DEFAULTS.backgroundColor;
|
|
1102
|
+
const fontFromCascade = parseFontFamily(cascade.all["font-family"]) || parseFontFamily(cascade.byTag["mj-text"]?.["font-family"]);
|
|
1103
|
+
const fontFromDeclaration = findByTag($, "mj-font").first().attr("name") ?? "";
|
|
1104
|
+
const fontFamily = fontFromCascade || fontFromDeclaration || REQUIRED_TEMPLATE_DEFAULTS.fontFamily;
|
|
1105
|
+
const textColor = parseColor(cascade.byTag["mj-text"]?.color) || REQUIRED_TEMPLATE_DEFAULTS.textColor;
|
|
1106
|
+
const previewText = findByTag($, "mj-preview").first().text().trim();
|
|
1107
|
+
const anchor = readAnchorRule(findByTag($, "mj-style").toArray().map((el) => $(el).text()).join("\n"));
|
|
1108
|
+
const locale = ($root.attr("lang") ?? "").trim() || REQUIRED_TEMPLATE_DEFAULTS.locale;
|
|
1109
|
+
const title = findByTag($, "mj-title").first().text().trim();
|
|
1110
|
+
if (title) warnings.push(`Dropped <mj-title> ("${title}") — Templatical templates have no document-title field.`);
|
|
1111
|
+
const $head = findByTag($, "mj-head").first();
|
|
1112
|
+
if ($head.length > 0) for (const $child of childElements($head, $)) {
|
|
1113
|
+
const tag = tagOf($child[0]);
|
|
1114
|
+
if (!CONSUMED_HEAD_TAGS.has(tag)) warnings.push(`Dropped <${tag}> — it has no Templatical equivalent.`);
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
width,
|
|
1118
|
+
backgroundColor,
|
|
1119
|
+
textColor,
|
|
1120
|
+
linkUnderline: anchor.underline ?? REQUIRED_TEMPLATE_DEFAULTS.linkUnderline,
|
|
1121
|
+
fontFamily,
|
|
1122
|
+
locale,
|
|
1123
|
+
...anchor.color ? { linkColor: anchor.color } : {},
|
|
1124
|
+
...previewText ? { preheaderText: previewText } : {}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
//#endregion
|
|
1128
|
+
//#region src/section-builder.ts
|
|
1129
|
+
/** Reverse of `packages/renderer/src/columns.ts`, keyed by column count. */
|
|
1130
|
+
const LAYOUT_SHAPES = [
|
|
1131
|
+
{
|
|
1132
|
+
layout: "1",
|
|
1133
|
+
percents: [100]
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
layout: "2",
|
|
1137
|
+
percents: [50, 50]
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
layout: "1-2",
|
|
1141
|
+
percents: [33.33, 66.67]
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
layout: "2-1",
|
|
1145
|
+
percents: [66.67, 33.33]
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
layout: "3",
|
|
1149
|
+
percents: [
|
|
1150
|
+
33.33,
|
|
1151
|
+
33.33,
|
|
1152
|
+
33.34
|
|
1153
|
+
]
|
|
1154
|
+
}
|
|
1155
|
+
];
|
|
1156
|
+
/** Percentage points of drift tolerated per column before a match is inexact. */
|
|
1157
|
+
const WIDTH_TOLERANCE = 2;
|
|
1158
|
+
/**
|
|
1159
|
+
* Resolve MJML column widths to one of the five layouts `ColumnLayout` allows.
|
|
1160
|
+
*
|
|
1161
|
+
* `exact: false` means the caller must report `approximated` — MJML permits any
|
|
1162
|
+
* number of columns at any width and this union permits five shapes, so this is
|
|
1163
|
+
* the importer's main irreducible loss (§8.1).
|
|
1164
|
+
*/
|
|
1165
|
+
function matchColumnLayout(percents) {
|
|
1166
|
+
const count = percents.length;
|
|
1167
|
+
if (count === 0) return {
|
|
1168
|
+
layout: "1",
|
|
1169
|
+
exact: true
|
|
1170
|
+
};
|
|
1171
|
+
if (percents.every((p) => p === null)) {
|
|
1172
|
+
if (count === 1) return {
|
|
1173
|
+
layout: "1",
|
|
1174
|
+
exact: true
|
|
1175
|
+
};
|
|
1176
|
+
if (count === 2) return {
|
|
1177
|
+
layout: "2",
|
|
1178
|
+
exact: true
|
|
1179
|
+
};
|
|
1180
|
+
if (count === 3) return {
|
|
1181
|
+
layout: "3",
|
|
1182
|
+
exact: true
|
|
1183
|
+
};
|
|
1184
|
+
return {
|
|
1185
|
+
layout: "3",
|
|
1186
|
+
exact: false
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
const nullCount = percents.filter((p) => p === null).length;
|
|
1190
|
+
const knownSum = percents.reduce((sum, p) => sum + (p ?? 0), 0);
|
|
1191
|
+
const remainder = nullCount > 0 ? Math.max(0, 100 - knownSum) / nullCount : 0;
|
|
1192
|
+
const resolved = percents.map((p) => p ?? remainder);
|
|
1193
|
+
const sameCount = LAYOUT_SHAPES.filter((shape) => shape.percents.length === count);
|
|
1194
|
+
for (const shape of sameCount) if (shape.percents.every((want, i) => Math.abs(want - resolved[i]) <= WIDTH_TOLERANCE)) return {
|
|
1195
|
+
layout: shape.layout,
|
|
1196
|
+
exact: true
|
|
1197
|
+
};
|
|
1198
|
+
const candidates = sameCount.length > 0 ? sameCount : LAYOUT_SHAPES.filter((shape) => shape.layout === "3");
|
|
1199
|
+
let best = candidates[0];
|
|
1200
|
+
let bestError = Infinity;
|
|
1201
|
+
for (const shape of candidates) {
|
|
1202
|
+
const error = shape.percents.reduce((sum, want, i) => sum + Math.abs(want - (resolved[i] ?? 0)), 0);
|
|
1203
|
+
if (error < bestError) {
|
|
1204
|
+
bestError = error;
|
|
1205
|
+
best = shape;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return {
|
|
1209
|
+
layout: best.layout,
|
|
1210
|
+
exact: false
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
const COLUMN_COUNT = {
|
|
1214
|
+
"1": 1,
|
|
1215
|
+
"2": 2,
|
|
1216
|
+
"3": 3,
|
|
1217
|
+
"2-1": 2,
|
|
1218
|
+
"1-2": 2
|
|
1219
|
+
};
|
|
1220
|
+
/** Column pixel widths per layout, mirroring `renderer/src/columns.ts`. */
|
|
1221
|
+
function columnPixels(layout, containerWidth) {
|
|
1222
|
+
switch (layout) {
|
|
1223
|
+
case "2": return [containerWidth * .5, containerWidth * .5];
|
|
1224
|
+
case "3": return [
|
|
1225
|
+
containerWidth / 3,
|
|
1226
|
+
containerWidth / 3,
|
|
1227
|
+
containerWidth / 3
|
|
1228
|
+
];
|
|
1229
|
+
case "1-2": return [containerWidth / 3, containerWidth * 2 / 3];
|
|
1230
|
+
case "2-1": return [containerWidth * 2 / 3, containerWidth / 3];
|
|
1231
|
+
default: return [containerWidth];
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* The `mj-column` elements of a section, in document order: a direct
|
|
1236
|
+
* `mj-column` child contributes itself, and an `mj-group` child contributes
|
|
1237
|
+
* every `mj-column` it holds at the group's own position — so a section
|
|
1238
|
+
* mixing direct columns with one or more groups keeps every column instead
|
|
1239
|
+
* of losing all but the first group's. `grouped` is true whenever any
|
|
1240
|
+
* `mj-group` child is present, which is what drives `stackOnMobile: false`.
|
|
1241
|
+
*/
|
|
1242
|
+
function readColumns($el, ctx) {
|
|
1243
|
+
const kids = childElements($el, ctx.$);
|
|
1244
|
+
const columns = [];
|
|
1245
|
+
let grouped = false;
|
|
1246
|
+
for (const $kid of kids) {
|
|
1247
|
+
const tag = tagOf($kid[0]);
|
|
1248
|
+
if (tag === "mj-column") columns.push($kid);
|
|
1249
|
+
else if (tag === "mj-group") {
|
|
1250
|
+
grouped = true;
|
|
1251
|
+
columns.push(...childElements($kid, ctx.$).filter(($k) => tagOf($k[0]) === "mj-column"));
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return {
|
|
1255
|
+
columns,
|
|
1256
|
+
grouped
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* A column's children, in document order, one block per unit `planSiblings`
|
|
1261
|
+
* groups them into.
|
|
1262
|
+
*
|
|
1263
|
+
* Routed through `planSiblings` for the same reason `walkBody` is
|
|
1264
|
+
* (`converter.ts`): the renderer wraps a conditional block in bracketing
|
|
1265
|
+
* `mj-raw` guards inside a column exactly as it does at top level
|
|
1266
|
+
* (`renderers/section.ts` calls the same `wrapWithDisplayCondition` helper as
|
|
1267
|
+
* `index.ts`), so recovering the condition here needs the identical fold.
|
|
1268
|
+
*/
|
|
1269
|
+
function convertColumnChildren($column, ctx, entries) {
|
|
1270
|
+
const blocks = [];
|
|
1271
|
+
for (const unit of planSiblings(childElements($column, ctx.$))) {
|
|
1272
|
+
const tag = tagOf(unit.$el[0]);
|
|
1273
|
+
if (tag === "mj-section" || tag === "mj-wrapper") {
|
|
1274
|
+
const attrs = resolveAttributes(unit.$el, ctx.cascade);
|
|
1275
|
+
const fallback = convertHtmlFallback(unit.$el, ctx, attrs);
|
|
1276
|
+
if (unit.displayCondition) fallback.displayCondition = unit.displayCondition;
|
|
1277
|
+
blocks.push(fallback);
|
|
1278
|
+
const noun = tag === "mj-section" ? "section" : "wrapper";
|
|
1279
|
+
entries.push({
|
|
1280
|
+
sourceTag: tag,
|
|
1281
|
+
templaticalBlockType: "html",
|
|
1282
|
+
status: "html-fallback",
|
|
1283
|
+
note: `MJML forbids <${tag}> inside <mj-column>; the nested ${noun}'s markup is preserved as an html block.`
|
|
1284
|
+
});
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
const converted = convertElement(unit.$el, ctx);
|
|
1288
|
+
if (!converted) continue;
|
|
1289
|
+
entries.push(converted.entry);
|
|
1290
|
+
if (!converted.block) continue;
|
|
1291
|
+
if (unit.displayCondition) converted.block.displayCondition = unit.displayCondition;
|
|
1292
|
+
blocks.push(converted.block);
|
|
1293
|
+
}
|
|
1294
|
+
return blocks;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* A column's width as a percentage of the section's container, or `null`
|
|
1298
|
+
* when the column carries no definite width of its own.
|
|
1299
|
+
*
|
|
1300
|
+
* `mj-column` accepts either a percentage or a px length, and a px width is
|
|
1301
|
+
* real geometry — converting it here lets it participate in
|
|
1302
|
+
* `matchColumnLayout` as a known value instead of falling through to "auto"
|
|
1303
|
+
* and losing the author's intended ratio.
|
|
1304
|
+
*/
|
|
1305
|
+
function columnWidthPercent(value, containerWidth) {
|
|
1306
|
+
const percent = parsePercent(value);
|
|
1307
|
+
if (percent !== null) return percent;
|
|
1308
|
+
const px = parseDefinitePx(value);
|
|
1309
|
+
return px !== null && containerWidth > 0 ? px / containerWidth * 100 : null;
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Build a `SectionBlock` (always exactly one) from an `mj-section`.
|
|
1313
|
+
*
|
|
1314
|
+
* Returns an array so the caller can treat sections and wrappers uniformly.
|
|
1315
|
+
*/
|
|
1316
|
+
function buildSection($el, ctx, entries, wrapper) {
|
|
1317
|
+
const attrs = resolveAttributes($el, ctx.cascade);
|
|
1318
|
+
const { columns, grouped } = readColumns($el, ctx);
|
|
1319
|
+
const rawWidths = columns.map(($c) => resolveAttributes($c, ctx.cascade).width);
|
|
1320
|
+
const { layout, exact } = matchColumnLayout(rawWidths.map((width) => columnWidthPercent(width, ctx.containerWidth)));
|
|
1321
|
+
const slots = COLUMN_COUNT[layout];
|
|
1322
|
+
const pixels = columnPixels(layout, ctx.containerWidth);
|
|
1323
|
+
const shown = rawWidths.map((w) => w || "auto").join(", ");
|
|
1324
|
+
entries.push({
|
|
1325
|
+
sourceTag: "mj-section",
|
|
1326
|
+
templaticalBlockType: "section",
|
|
1327
|
+
status: exact ? "converted" : "approximated",
|
|
1328
|
+
...exact ? {} : { note: `Column widths ${shown} have no exact Templatical layout; resolved to "${layout}".` }
|
|
1329
|
+
});
|
|
1330
|
+
const children = Array.from({ length: slots }, () => []);
|
|
1331
|
+
columns.forEach(($column, index) => {
|
|
1332
|
+
const slot = Math.min(index, slots - 1);
|
|
1333
|
+
const columnCtx = {
|
|
1334
|
+
...ctx,
|
|
1335
|
+
containerWidth: Math.round(pixels[slot] ?? ctx.containerWidth)
|
|
1336
|
+
};
|
|
1337
|
+
children[slot].push(...convertColumnChildren($column, columnCtx, entries));
|
|
1338
|
+
});
|
|
1339
|
+
const borderRadius = parsePxValue(attrs["border-radius"]);
|
|
1340
|
+
return [createSectionBlock({
|
|
1341
|
+
columns: layout,
|
|
1342
|
+
children,
|
|
1343
|
+
...grouped ? { stackOnMobile: false } : {},
|
|
1344
|
+
...borderRadius > 0 ? { borderRadius } : {},
|
|
1345
|
+
...wrapper ? { wrapper } : {},
|
|
1346
|
+
...baseFields(attrs, "native")
|
|
1347
|
+
})];
|
|
1348
|
+
}
|
|
1349
|
+
function readWrapper(attrs) {
|
|
1350
|
+
const backgroundColor = parseColor(attrs["background-color"]);
|
|
1351
|
+
const padding = parsePaddingShorthand(attrs.padding);
|
|
1352
|
+
const borderRadius = parsePxValue(attrs["border-radius"]);
|
|
1353
|
+
return {
|
|
1354
|
+
...backgroundColor ? { backgroundColor } : {},
|
|
1355
|
+
padding,
|
|
1356
|
+
...borderRadius > 0 ? { borderRadius } : {}
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Fold an `mj-wrapper` into the `wrapper` field of the section(s) it holds.
|
|
1361
|
+
*
|
|
1362
|
+
* A wrapper is not a block: `SectionWrapper` is exactly the band the renderer
|
|
1363
|
+
* emits an `mj-wrapper` for (`renderer/src/index.ts:223`), so representing it
|
|
1364
|
+
* as its own section would double the nesting on every round trip.
|
|
1365
|
+
*/
|
|
1366
|
+
function buildWrapper($el, ctx, entries) {
|
|
1367
|
+
const wrapper = readWrapper(resolveAttributes($el, ctx.cascade));
|
|
1368
|
+
const sections = childElements($el, ctx.$).filter(($k) => tagOf($k[0]) === "mj-section");
|
|
1369
|
+
if (sections.length === 0) {
|
|
1370
|
+
entries.push({
|
|
1371
|
+
sourceTag: "mj-wrapper",
|
|
1372
|
+
templaticalBlockType: null,
|
|
1373
|
+
status: "skipped",
|
|
1374
|
+
note: "An <mj-wrapper> with no <mj-section> children produces nothing."
|
|
1375
|
+
});
|
|
1376
|
+
return [];
|
|
1377
|
+
}
|
|
1378
|
+
if (sections.length > 1) entries.push({
|
|
1379
|
+
sourceTag: "mj-wrapper",
|
|
1380
|
+
templaticalBlockType: "section",
|
|
1381
|
+
status: "approximated",
|
|
1382
|
+
note: `An <mj-wrapper> holding ${sections.length} sections was applied to each of them — Templatical has no multi-section band.`
|
|
1383
|
+
});
|
|
1384
|
+
return sections.flatMap(($section) => buildSection($section, ctx, entries, wrapper));
|
|
1385
|
+
}
|
|
1386
|
+
//#endregion
|
|
1387
|
+
//#region src/converter.ts
|
|
1388
|
+
const EMPTY_DOCUMENT_WARNING = "No convertible content was found in the MJML. Check that the document has an <mj-body> with at least one <mj-section>.";
|
|
1389
|
+
/**
|
|
1390
|
+
* Wrap blocks that sat directly under `mj-body` in a one-column section.
|
|
1391
|
+
*
|
|
1392
|
+
* Valid MJML puts every block inside an `mj-section`, but hand-written and
|
|
1393
|
+
* machine-mangled documents do not, and the editor canvas has no
|
|
1394
|
+
* representation for a block outside a section.
|
|
1395
|
+
*/
|
|
1396
|
+
function wrapInSection(blocks) {
|
|
1397
|
+
return createSectionBlock({
|
|
1398
|
+
columns: "1",
|
|
1399
|
+
children: [blocks],
|
|
1400
|
+
styles: { padding: {
|
|
1401
|
+
top: 0,
|
|
1402
|
+
right: 0,
|
|
1403
|
+
bottom: 0,
|
|
1404
|
+
left: 0
|
|
1405
|
+
} }
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
function walkBody($body, ctx, entries) {
|
|
1409
|
+
const blocks = [];
|
|
1410
|
+
let loose = [];
|
|
1411
|
+
const flushLoose = () => {
|
|
1412
|
+
if (loose.length > 0) {
|
|
1413
|
+
blocks.push(wrapInSection(loose));
|
|
1414
|
+
loose = [];
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
for (const unit of planSiblings(childElements($body, ctx.$))) {
|
|
1418
|
+
const tag = tagOf(unit.$el[0]);
|
|
1419
|
+
if (tag === "mj-wrapper" || tag === "mj-section") {
|
|
1420
|
+
flushLoose();
|
|
1421
|
+
const produced = tag === "mj-wrapper" ? buildWrapper(unit.$el, ctx, entries) : buildSection(unit.$el, ctx, entries);
|
|
1422
|
+
for (const block of produced) {
|
|
1423
|
+
if (unit.displayCondition) block.displayCondition = unit.displayCondition;
|
|
1424
|
+
blocks.push(block);
|
|
1425
|
+
}
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
const converted = convertElement(unit.$el, ctx);
|
|
1429
|
+
if (!converted) continue;
|
|
1430
|
+
entries.push(converted.entry);
|
|
1431
|
+
if (!converted.block) continue;
|
|
1432
|
+
if (unit.displayCondition) converted.block.displayCondition = unit.displayCondition;
|
|
1433
|
+
if (converted.entry.status === "html-fallback") {
|
|
1434
|
+
flushLoose();
|
|
1435
|
+
blocks.push(converted.block);
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
loose.push(converted.block);
|
|
1439
|
+
}
|
|
1440
|
+
flushLoose();
|
|
1441
|
+
return blocks;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Convert an MJML document into a Templatical template.
|
|
1445
|
+
*
|
|
1446
|
+
* @example
|
|
1447
|
+
* ```ts
|
|
1448
|
+
* const { content, report } = convertMjmlTemplate(mjmlSource);
|
|
1449
|
+
*
|
|
1450
|
+
* const editor = init({ container: '#editor', content });
|
|
1451
|
+
*
|
|
1452
|
+
* console.log(report.summary);
|
|
1453
|
+
* console.log(report.warnings);
|
|
1454
|
+
* ```
|
|
1455
|
+
*/
|
|
1456
|
+
function convertMjmlTemplate(mjml) {
|
|
1457
|
+
if (typeof mjml !== "string") throw new Error("Invalid MJML template: expected a string. Pass the raw MJML source as a string.");
|
|
1458
|
+
if (mjml.trim().length === 0) throw new Error("Invalid MJML template: input is empty. Pass the raw MJML source of an email.");
|
|
1459
|
+
const $ = load(mjml, { xml: {
|
|
1460
|
+
xmlMode: false,
|
|
1461
|
+
recognizeSelfClosing: true
|
|
1462
|
+
} });
|
|
1463
|
+
const cascade = buildAttributeCascade($);
|
|
1464
|
+
const entries = [];
|
|
1465
|
+
const warnings = [];
|
|
1466
|
+
const settings = extractSettings($, cascade, warnings);
|
|
1467
|
+
const $body = findByTag($, "mj-body").first();
|
|
1468
|
+
const ctx = {
|
|
1469
|
+
$,
|
|
1470
|
+
cascade,
|
|
1471
|
+
containerWidth: settings.width,
|
|
1472
|
+
warnings
|
|
1473
|
+
};
|
|
1474
|
+
const blocks = $body.length > 0 ? walkBody($body, ctx, entries) : [];
|
|
1475
|
+
if (blocks.length === 0) warnings.push(EMPTY_DOCUMENT_WARNING);
|
|
1476
|
+
return {
|
|
1477
|
+
content: {
|
|
1478
|
+
...createDefaultTemplateContent(),
|
|
1479
|
+
blocks,
|
|
1480
|
+
settings
|
|
1481
|
+
},
|
|
1482
|
+
report: {
|
|
1483
|
+
entries,
|
|
1484
|
+
warnings,
|
|
1485
|
+
summary: {
|
|
1486
|
+
total: entries.length,
|
|
1487
|
+
converted: entries.filter((e) => e.status === "converted").length,
|
|
1488
|
+
approximated: entries.filter((e) => e.status === "approximated").length,
|
|
1489
|
+
htmlFallback: entries.filter((e) => e.status === "html-fallback").length,
|
|
1490
|
+
skipped: entries.filter((e) => e.status === "skipped").length
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
//#endregion
|
|
1496
|
+
export { convertMjmlTemplate };
|
|
1497
|
+
|
|
1498
|
+
//# sourceMappingURL=index.js.map
|