@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/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