@templatical/import-mjml 0.45.0 → 0.47.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import { load } from "cheerio";
2
- import { DEFAULT_TEMPLATE_DEFAULTS, RICH_TEXT_SPACING, SOCIAL_ICON_STYLES, SOCIAL_ICON_TONES, SYNTAX_PRESETS, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTableBlock, createTitleBlock, generateId, resolveContentDirection } from "@templatical/types";
2
+ import { BORDER_SIDES, DEFAULT_TEMPLATE_DEFAULTS, RICH_TEXT_SPACING, SOCIAL_ICON_STYLES, SOCIAL_ICON_TONES, SYNTAX_PRESETS, canonicalFontStack, createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createMenuBlock, createParagraphBlock, createSectionBlock, createSocialIconsBlock, createSpacerBlock, createTableBlock, createTitleBlock, generateId, resolveContentDirection } from "@templatical/types";
3
3
  import { isTag, isText } from "domhandler";
4
4
  //#region src/attribute-resolver.ts
5
5
  /**
@@ -321,13 +321,6 @@ function parsePaddingShorthand(value) {
321
321
  }
322
322
  }
323
323
  /**
324
- * Strips quotes and returns the first font in a font-family stack.
325
- */
326
- function parseFontFamily(value) {
327
- if (!value) return "";
328
- return value.split(",")[0].trim().replace(/^['"]|['"]$/g, "");
329
- }
330
- /**
331
324
  * Parses an alignment to one of the three the block model accepts.
332
325
  */
333
326
  function parseAlignment(value, fallback = "left") {
@@ -361,6 +354,114 @@ function parseDefinitePx(value) {
361
354
  const match = value.trim().match(/^(-?\d+(?:\.\d+)?)\s*(?:px)?$/);
362
355
  return match ? parseFloat(match[1]) : null;
363
356
  }
357
+ const PX_LENGTH = /^(\d+(?:\.\d+)?)(?:px)?$/i;
358
+ /**
359
+ * Reads a `border-radius` value: one px length, or the two- to four-value
360
+ * shorthand (top-left, top-right, bottom-right, bottom-left), expanded the way
361
+ * CSS expands it. Four matching corners come back as one number, the form the
362
+ * renderer writes them in. `null` when any part is not a non-negative px
363
+ * length (`50%`, `1em`, an elliptical `8px / 4px`), which CSS either reads
364
+ * against the box or discards.
365
+ */
366
+ function parseBorderRadius(value) {
367
+ const parts = value.trim().split(/\s+/);
368
+ if (parts.length > 4) return null;
369
+ const px = [];
370
+ for (const part of parts) {
371
+ const match = PX_LENGTH.exec(part);
372
+ if (!match) return null;
373
+ px.push(Math.round(parseFloat(match[1])));
374
+ }
375
+ const [topLeft, topRight = topLeft, bottomRight = topLeft, bottomLeft = topRight] = px;
376
+ if (topRight === topLeft && bottomRight === topLeft && bottomLeft === topLeft) return topLeft;
377
+ return {
378
+ topLeft,
379
+ topRight,
380
+ bottomRight,
381
+ bottomLeft
382
+ };
383
+ }
384
+ const DRAWN_STYLES = /* @__PURE__ */ new Set([
385
+ "solid",
386
+ "dashed",
387
+ "dotted"
388
+ ]);
389
+ const NARROWED_STYLES = /* @__PURE__ */ new Set([
390
+ "double",
391
+ "groove",
392
+ "ridge",
393
+ "inset",
394
+ "outset"
395
+ ]);
396
+ const UNDRAWN_STYLES = /* @__PURE__ */ new Set(["none", "hidden"]);
397
+ /**
398
+ * Splits a CSS value on whitespace outside parentheses, so `rgb(0, 0, 0)` stays
399
+ * one token. A linear scan rather than a regex, which would backtrack on a long
400
+ * run of spaces.
401
+ */
402
+ function splitCssTokens(value) {
403
+ const tokens = [];
404
+ let current = "";
405
+ let depth = 0;
406
+ for (const ch of value) {
407
+ if (ch === "(") depth++;
408
+ if (ch === ")") depth = Math.max(0, depth - 1);
409
+ if (depth === 0 && /\s/.test(ch)) {
410
+ if (current) tokens.push(current);
411
+ current = "";
412
+ continue;
413
+ }
414
+ current += ch;
415
+ }
416
+ if (current) tokens.push(current);
417
+ return tokens;
418
+ }
419
+ /**
420
+ * Reads one CSS `border` declaration, its width, style and colour in any
421
+ * order, as a `BorderSideValue`.
422
+ *
423
+ * `side` is `null` when CSS draws nothing: style `none` or `hidden`, no style
424
+ * at all (CSS's initial style is `none`), or a `0` width. A style the block
425
+ * model lacks narrows to solid and is named in `narrowedFrom`. A missing or
426
+ * unknown colour is `""`, which the renderer writes back as no colour, so CSS
427
+ * draws `currentColor` both times.
428
+ *
429
+ * Returns `null` for a drawn border it cannot hold: a width that is not px
430
+ * (`2em`, `thin`), no width at all (CSS draws `medium`), or a value with two
431
+ * of any part.
432
+ */
433
+ function parseBorderDeclaration(value) {
434
+ let width;
435
+ let style;
436
+ let color;
437
+ for (const token of splitCssTokens(value.trim())) {
438
+ const lower = token.toLowerCase();
439
+ const px = PX_LENGTH.exec(lower);
440
+ const isStyle = DRAWN_STYLES.has(lower) || NARROWED_STYLES.has(lower) || UNDRAWN_STYLES.has(lower);
441
+ if (px) {
442
+ if (width !== void 0) return null;
443
+ width = Math.round(parseFloat(px[1]));
444
+ } else if (isStyle) {
445
+ if (style !== void 0) return null;
446
+ style = lower;
447
+ } else if (/^[-+.\d]/.test(lower) || /^(thin|medium|thick)$/.test(lower)) return null;
448
+ else {
449
+ if (color !== void 0) return null;
450
+ color = token;
451
+ }
452
+ }
453
+ if (style === void 0 || UNDRAWN_STYLES.has(style) || width === 0) return { side: null };
454
+ if (width === void 0) return null;
455
+ const side = {
456
+ width,
457
+ style: NARROWED_STYLES.has(style) ? "solid" : style,
458
+ color: parseColor(color)
459
+ };
460
+ return NARROWED_STYLES.has(style) ? {
461
+ side,
462
+ narrowedFrom: style
463
+ } : { side };
464
+ }
364
465
  /**
365
466
  * Narrows a border style to the three `DividerBlock.lineStyle` accepts.
366
467
  */
@@ -369,6 +470,197 @@ function parseBorderStyle(value) {
369
470
  if (v === "dashed" || v === "dotted") return v;
370
471
  return "solid";
371
472
  }
473
+ const X_GRID = [
474
+ "left",
475
+ "center",
476
+ "right"
477
+ ];
478
+ const Y_GRID = [
479
+ "top",
480
+ "center",
481
+ "bottom"
482
+ ];
483
+ const PERCENT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))%$/;
484
+ const LENGTH = /^-?(?:\d+(?:\.\d+)?|\.\d+)[a-z]*$/;
485
+ /** What MJML paints for a `background-*` attribute an `mj-section` / `mj-wrapper` leaves out. */
486
+ const MJML_BACKGROUND_DEFAULTS = {
487
+ size: "auto",
488
+ x: "center",
489
+ y: "top",
490
+ repeat: true
491
+ };
492
+ /**
493
+ * `background-size` as a keyword. A size with a side at 100%, or a px width
494
+ * spanning the box, fills it as `cover` does; any other length or percentage
495
+ * draws the image at a size of its own, which `auto` is closest to.
496
+ */
497
+ function backgroundSizeKeyword(raw, boxWidth) {
498
+ const parts = raw.toLowerCase().split(/\s+/);
499
+ const [first] = parts;
500
+ if (parts.length === 1 && (first === "cover" || first === "contain")) return {
501
+ size: first,
502
+ exact: true
503
+ };
504
+ if (parts.every((part) => part === "auto")) return {
505
+ size: "auto",
506
+ exact: true
507
+ };
508
+ const px = /^(\d+(?:\.\d+)?|\.\d+)px$/.exec(first);
509
+ return {
510
+ size: parts.includes("100%") || px !== null && boxWidth !== void 0 && Number(px[1]) >= boxWidth ? "cover" : "auto",
511
+ exact: false
512
+ };
513
+ }
514
+ /**
515
+ * `background-position` split into x and y the way MJML and CSS read it: a
516
+ * lone `top` / `bottom` names y and centres x, any other lone value is x
517
+ * with y centred, and a pair is x then y unless it leads with `top` /
518
+ * `bottom` (or with `center` before `left` / `right`). Three- and four-value
519
+ * edge offsets keep their edge keywords only.
520
+ */
521
+ function splitBackgroundPosition(raw) {
522
+ const parts = raw.toLowerCase().split(/\s+/);
523
+ if (parts.length === 1) {
524
+ const [only] = parts;
525
+ return only === "top" || only === "bottom" ? {
526
+ x: "center",
527
+ y: only,
528
+ exact: true
529
+ } : {
530
+ x: only,
531
+ y: "center",
532
+ exact: true
533
+ };
534
+ }
535
+ if (parts.length === 2) {
536
+ const [a, b] = parts;
537
+ return a === "top" || a === "bottom" || a === "center" && (b === "left" || b === "right") ? {
538
+ x: b,
539
+ y: a,
540
+ exact: true
541
+ } : {
542
+ x: a,
543
+ y: b,
544
+ exact: true
545
+ };
546
+ }
547
+ return {
548
+ x: parts.find((part) => part === "left" || part === "right") ?? "center",
549
+ y: parts.find((part) => part === "top" || part === "bottom") ?? "center",
550
+ exact: false
551
+ };
552
+ }
553
+ /**
554
+ * One axis as a grid point: its keyword; the point nearest a percentage,
555
+ * ties going to the centre; or the start edge for a length, which is an
556
+ * offset from it. `null` when the token is unreadable.
557
+ */
558
+ function backgroundAxisPoint(token, grid) {
559
+ const keyword = grid.find((point) => point === token);
560
+ if (keyword) return {
561
+ point: keyword,
562
+ exact: true
563
+ };
564
+ const percent = PERCENT.exec(token);
565
+ if (percent) {
566
+ const n = Number(percent[1]);
567
+ return {
568
+ point: n < 25 ? grid[0] : n > 75 ? grid[2] : grid[1],
569
+ exact: n === 0 || n === 50 || n === 100
570
+ };
571
+ }
572
+ if (LENGTH.test(token)) return {
573
+ point: grid[0],
574
+ exact: parseFloat(token) === 0
575
+ };
576
+ return null;
577
+ }
578
+ /**
579
+ * `background-repeat`: unrepeated only when every axis is `no-repeat`.
580
+ * Anything but plain `repeat` tiles too, inexactly.
581
+ */
582
+ function backgroundRepeatFlag(raw) {
583
+ const parts = raw.toLowerCase().split(/\s+/);
584
+ if (parts.every((part) => part === "no-repeat")) return {
585
+ repeat: false,
586
+ exact: true
587
+ };
588
+ return {
589
+ repeat: true,
590
+ exact: parts.every((part) => part === "repeat")
591
+ };
592
+ }
593
+ /**
594
+ * An `mj-section` / `mj-wrapper` background image, `null` when
595
+ * `background-url` is absent or blank. `read` returns an attribute's
596
+ * resolved value. Unset attributes take MJML's own defaults, so the import
597
+ * paints what MJML painted; a value with no exact keyword takes the nearest
598
+ * one and adds a note naming it. `label` names the element in each note, and
599
+ * a px `background-size` is measured against `boxWidth`.
600
+ */
601
+ function readMjmlBackgroundImage(read, label, boxWidth) {
602
+ const url = (read("background-url") ?? "").trim();
603
+ if (!url) return null;
604
+ const notes = [];
605
+ const lossy = (name, raw, shown) => {
606
+ notes.push(`${label} ${name} "${raw}" has no exact Templatical equivalent; imported as "${shown}".`);
607
+ };
608
+ let size = MJML_BACKGROUND_DEFAULTS.size;
609
+ const rawSize = read("background-size")?.trim();
610
+ if (rawSize) {
611
+ const parsed = backgroundSizeKeyword(rawSize, boxWidth);
612
+ size = parsed.size;
613
+ if (!parsed.exact) lossy("background-size", rawSize, size);
614
+ }
615
+ let x = MJML_BACKGROUND_DEFAULTS.x;
616
+ let y = MJML_BACKGROUND_DEFAULTS.y;
617
+ let lossyX = false;
618
+ let lossyY = false;
619
+ const rawPosition = read("background-position")?.trim();
620
+ if (rawPosition) {
621
+ const split = splitBackgroundPosition(rawPosition);
622
+ const readX = backgroundAxisPoint(split.x, X_GRID);
623
+ const readY = backgroundAxisPoint(split.y, Y_GRID);
624
+ x = readX?.point ?? x;
625
+ y = readY?.point ?? y;
626
+ lossyX = !split.exact || !readX?.exact;
627
+ lossyY = !split.exact || !readY?.exact;
628
+ }
629
+ const rawX = read("background-position-x")?.trim();
630
+ const overrideX = rawX ? backgroundAxisPoint(rawX.toLowerCase(), X_GRID) : null;
631
+ if (overrideX) {
632
+ x = overrideX.point;
633
+ lossyX = false;
634
+ }
635
+ const rawY = read("background-position-y")?.trim();
636
+ const overrideY = rawY ? backgroundAxisPoint(rawY.toLowerCase(), Y_GRID) : null;
637
+ if (overrideY) {
638
+ y = overrideY.point;
639
+ lossyY = false;
640
+ }
641
+ if (rawPosition && (lossyX || lossyY)) lossy("background-position", rawPosition, `${x} ${y}`);
642
+ if (rawX && !overrideX?.exact) lossy("background-position-x", rawX, x);
643
+ if (rawY && !overrideY?.exact) lossy("background-position-y", rawY, y);
644
+ let repeat = MJML_BACKGROUND_DEFAULTS.repeat;
645
+ const rawRepeat = read("background-repeat")?.trim();
646
+ if (rawRepeat) {
647
+ const parsed = backgroundRepeatFlag(rawRepeat);
648
+ repeat = parsed.repeat;
649
+ if (!parsed.exact) lossy("background-repeat", rawRepeat, repeat ? "repeat" : "no-repeat");
650
+ }
651
+ return {
652
+ image: {
653
+ url,
654
+ size,
655
+ position: {
656
+ x,
657
+ y
658
+ },
659
+ repeat
660
+ },
661
+ notes
662
+ };
663
+ }
372
664
  //#endregion
373
665
  //#region src/block-base.ts
374
666
  /**
@@ -399,6 +691,65 @@ function baseFields(attrs, placement = "container") {
399
691
  ...visibility ? { visibility } : {}
400
692
  };
401
693
  }
694
+ /**
695
+ * `border-radius` as a `BorderRadiusValue`, `undefined` when the attribute is
696
+ * absent. A value with no px reading is square corners, `0`, and pushes a
697
+ * note: `label` names the element in it.
698
+ */
699
+ function readBorderRadius(attrs, label, notes) {
700
+ const raw = attrs["border-radius"];
701
+ if (raw === void 0) return void 0;
702
+ const radius = parseBorderRadius(raw);
703
+ if (radius === null) {
704
+ notes.push(`${label} border-radius "${raw.trim()}" could not be read; imported as square corners.`);
705
+ return 0;
706
+ }
707
+ return radius;
708
+ }
709
+ /**
710
+ * What the editor's border control holds for a side with no width
711
+ * (`BorderControl.vue`), so a border drawn on some sides reads back the way
712
+ * the editor would have stored it.
713
+ */
714
+ const UNDRAWN_SIDE = {
715
+ width: 0,
716
+ style: "solid",
717
+ color: "#000000"
718
+ };
719
+ /**
720
+ * `border` and `border-<side>` as a `BorderValue`, `undefined` when no side is
721
+ * drawn. A side takes its own `border-<side>` over `border`, since MJML writes
722
+ * the side after the shorthand. A declaration that a side actually uses pushes
723
+ * a note when its style narrows to solid or it cannot be read, in which case
724
+ * that side is left undrawn.
725
+ */
726
+ function readBorder(attrs, label, notes) {
727
+ const read = /* @__PURE__ */ new Map();
728
+ const sides = {};
729
+ for (const side of BORDER_SIDES) {
730
+ const name = attrs[`border-${side}`] !== void 0 ? `border-${side}` : attrs.border !== void 0 ? "border" : null;
731
+ if (name === null) {
732
+ sides[side] = null;
733
+ continue;
734
+ }
735
+ if (!read.has(name)) {
736
+ const raw = attrs[name].trim();
737
+ const parsed = parseBorderDeclaration(raw);
738
+ if (parsed === null) notes.push(`${label} ${name} "${raw}" could not be read; imported without it.`);
739
+ else if (parsed.narrowedFrom) notes.push(`${label} ${name} "${raw}" uses style ${parsed.narrowedFrom}; imported as solid.`);
740
+ read.set(name, parsed?.side ?? null);
741
+ }
742
+ const parsed = read.get(name);
743
+ sides[side] = parsed && { ...parsed };
744
+ }
745
+ if (BORDER_SIDES.every((side) => sides[side] === null)) return void 0;
746
+ return {
747
+ top: sides.top ?? { ...UNDRAWN_SIDE },
748
+ right: sides.right ?? { ...UNDRAWN_SIDE },
749
+ bottom: sides.bottom ?? { ...UNDRAWN_SIDE },
750
+ left: sides.left ?? { ...UNDRAWN_SIDE }
751
+ };
752
+ }
402
753
  function warnForeignClasses(attrs, tag, ctx) {
403
754
  for (const name of readForeignCssClasses(attrs)) ctx.warnings.push(`Dropped CSS class "${name}" on <${tag}> — consumer CSS has no Templatical equivalent.`);
404
755
  }
@@ -609,7 +960,7 @@ function convertNavbar($el, attrs, ctx) {
609
960
  };
610
961
  });
611
962
  const fontSize = parsePxValue(attrs["font-size"]);
612
- const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-navbar", ctx.cascade));
963
+ const fontFamily = canonicalFontStack(ownAttr(attrs, "font-family", "mj-navbar", ctx.cascade));
613
964
  return {
614
965
  block: createMenuBlock({
615
966
  items,
@@ -639,7 +990,7 @@ function convertNativeTable($el, attrs, ctx) {
639
990
  const hasHeaderRow = $(rowEls[0]).children().toArray().some((cell) => tagOf(cell) === "th");
640
991
  const color = parseColor(ownAttr(attrs, "color", "mj-table", ctx.cascade));
641
992
  const fontSize = parsePxValue(attrs["font-size"]);
642
- const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-table", ctx.cascade));
993
+ const fontFamily = canonicalFontStack(ownAttr(attrs, "font-family", "mj-table", ctx.cascade));
643
994
  return {
644
995
  block: createTableBlock({
645
996
  rows,
@@ -694,7 +1045,7 @@ function convertTitle(html, attrs, sourceLevel, cascade) {
694
1045
  const $heading = parseInner(html)("body").children().first();
695
1046
  const level = Math.min(sourceLevel, 4);
696
1047
  const color = parseColor(ownAttr(attrs, "color", "mj-text", cascade));
697
- const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-text", cascade));
1048
+ const fontFamily = canonicalFontStack(ownAttr(attrs, "font-family", "mj-text", cascade));
698
1049
  const block = createTitleBlock({
699
1050
  content: $heading.html() ?? "",
700
1051
  level,
@@ -727,7 +1078,7 @@ function convertTable(html, attrs, cascade) {
727
1078
  const hasHeaderRow = $rows.length > 0 && $inner($rows[0]).children("th").length > 0;
728
1079
  const color = parseColor(ownAttr(attrs, "color", "mj-text", cascade));
729
1080
  const fontSize = parsePxValue(attrs["font-size"]);
730
- const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-text", cascade));
1081
+ const fontFamily = canonicalFontStack(ownAttr(attrs, "font-family", "mj-text", cascade));
731
1082
  return {
732
1083
  block: createTableBlock({
733
1084
  rows,
@@ -792,7 +1143,7 @@ function convertMenu(html, attrs, cascade) {
792
1143
  const spacing = parsePxValue($separator.attr("style")?.match(/padding\s*:\s*0\s+([\d.]+px)/i)?.[1]);
793
1144
  const color = parseColor(ownAttr(attrs, "color", "mj-text", cascade));
794
1145
  const fontSize = parsePxValue(attrs["font-size"]);
795
- const fontFamily = parseFontFamily(ownAttr(attrs, "font-family", "mj-text", cascade));
1146
+ const fontFamily = canonicalFontStack(ownAttr(attrs, "font-family", "mj-text", cascade));
796
1147
  return {
797
1148
  block: createMenuBlock({
798
1149
  items,
@@ -985,39 +1336,105 @@ function convertImage($el, attrs, ctx) {
985
1336
  const decorative = (attrs.role ?? "").trim().toLowerCase() === "presentation";
986
1337
  const pxWidth = parsePxValue(attrs.width);
987
1338
  const height = parsePxValue(attrs.height);
988
- const borderRadius = parsePxValue(attrs["border-radius"]);
989
1339
  const href = (attrs.href ?? "").trim();
990
- return createImageBlock({
991
- src,
992
- alt: decorative ? "" : attrs.alt ?? "",
993
- width: pxWidth === ctx.containerWidth ? "full" : pxWidth || "full",
994
- align: parseAlignment(attrs.align, "center"),
995
- ...height > 0 ? { height } : {},
996
- ...borderRadius > 0 ? { borderRadius } : {},
997
- ...href ? { linkUrl: href } : {},
998
- ...href && isNewTab(attrs) ? { linkOpenInNewTab: true } : {},
999
- ...decorative ? { decorative: true } : {},
1000
- ...baseFields(attrs)
1001
- });
1340
+ const notes = [];
1341
+ const borderRadius = readBorderRadius(attrs, "Image", notes);
1342
+ const border = readBorder(attrs, "Image", notes);
1343
+ return {
1344
+ block: createImageBlock({
1345
+ src,
1346
+ alt: decorative ? "" : attrs.alt ?? "",
1347
+ width: pxWidth === ctx.containerWidth ? "full" : pxWidth || "full",
1348
+ align: parseAlignment(attrs.align, "center"),
1349
+ ...height > 0 ? { height } : {},
1350
+ ...borderRadius ? { borderRadius } : {},
1351
+ ...border ? { border } : {},
1352
+ ...href ? { linkUrl: href } : {},
1353
+ ...href && isNewTab(attrs) ? { linkOpenInNewTab: true } : {},
1354
+ ...decorative ? { decorative: true } : {},
1355
+ ...baseFields(attrs)
1356
+ }),
1357
+ entry: {
1358
+ sourceTag: "mj-image",
1359
+ templaticalBlockType: "image",
1360
+ status: notes.length > 0 ? "approximated" : "converted",
1361
+ ...notes.length > 0 ? { note: notes.join(" ") } : {}
1362
+ }
1363
+ };
1364
+ }
1365
+ /**
1366
+ * `mj-button`'s `width` as `ButtonBlock.width`, where no width is a button
1367
+ * that fits its label. `100%` is `"full"` and a px width stays px, as the
1368
+ * renderer writes them. Any other percentage becomes px of `fillWidth`, the
1369
+ * box MJML sizes it against, since the block holds no percentage. A width
1370
+ * with no size fits the label, which is what MJML draws for it. Anything but
1371
+ * `100%` or a positive px width pushes a note.
1372
+ */
1373
+ function readButtonWidth(value, fillWidth, notes) {
1374
+ const raw = (value ?? "").trim();
1375
+ if (!raw) return void 0;
1376
+ const match = /^(-?\d+(?:\.\d+)?)\s*(px|%)?$/i.exec(raw);
1377
+ if (!match) {
1378
+ notes.push(`Button width "${raw}" could not be read; imported as fit to content.`);
1379
+ return;
1380
+ }
1381
+ const parsed = parseFloat(match[1]);
1382
+ const isPercent = match[2] === "%";
1383
+ if (isPercent && parsed >= 100) {
1384
+ if (parsed > 100) notes.push(`Button width ${raw} was clamped to 100%.`);
1385
+ return "full";
1386
+ }
1387
+ const px = Math.round(isPercent ? parsed / 100 * fillWidth : parsed);
1388
+ if (px <= 0) {
1389
+ notes.push(`Button width ${raw} has no usable size; imported as fit to content.`);
1390
+ return;
1391
+ }
1392
+ if (isPercent) notes.push(`Button width ${raw} was imported as ${px}px, which does not scale with its column.`);
1393
+ return px;
1002
1394
  }
1003
- function convertButton($el, attrs) {
1395
+ /**
1396
+ * A percentage `width` sizes the button against its column less the column's
1397
+ * side padding and its own, the same box an `mj-divider` line spans.
1398
+ *
1399
+ * `font-family` is read only when the button sets its own: the renderer
1400
+ * writes the document font onto `mj-all`, which reaches every button.
1401
+ */
1402
+ function convertButton($el, attrs, ctx) {
1004
1403
  const text = ($el.text() ?? "").trim();
1005
1404
  if (!text) return null;
1006
1405
  const backgroundColor = parseColor(attrs["background-color"]);
1007
1406
  const textColor = parseColor(attrs.color);
1008
1407
  const fontSize = parsePxValue(attrs["font-size"]);
1009
- return createButtonBlock({
1010
- text,
1011
- url: (attrs.href ?? "").trim(),
1012
- ...backgroundColor ? { backgroundColor } : {},
1013
- ...textColor ? { textColor } : {},
1014
- ...fontSize > 0 ? { fontSize } : {},
1015
- ...attrs["border-radius"] !== void 0 ? { borderRadius: parsePxValue(attrs["border-radius"]) } : {},
1016
- ...attrs["inner-padding"] !== void 0 ? { buttonPadding: parsePaddingShorthand(attrs["inner-padding"]) } : {},
1017
- align: parseAlignment(attrs.align, "center"),
1018
- ...isNewTab(attrs) ? { openInNewTab: true } : {},
1019
- ...baseFields(attrs)
1020
- });
1408
+ const fontFamily = canonicalFontStack(ownAttr(attrs, "font-family", "mj-button", ctx.cascade));
1409
+ const base = baseFields(attrs);
1410
+ const { left, right } = base.styles.padding;
1411
+ const notes = [];
1412
+ const width = readButtonWidth(attrs.width, ctx.containerWidth - ctx.columnPadding - left - right, notes);
1413
+ const borderRadius = readBorderRadius(attrs, "Button", notes);
1414
+ const border = readBorder(attrs, "Button", notes);
1415
+ return {
1416
+ block: createButtonBlock({
1417
+ text,
1418
+ url: (attrs.href ?? "").trim(),
1419
+ ...backgroundColor ? { backgroundColor } : {},
1420
+ ...textColor ? { textColor } : {},
1421
+ ...fontSize > 0 ? { fontSize } : {},
1422
+ ...fontFamily ? { fontFamily } : {},
1423
+ ...borderRadius !== void 0 ? { borderRadius } : {},
1424
+ ...border ? { border } : {},
1425
+ ...attrs["inner-padding"] !== void 0 ? { buttonPadding: parsePaddingShorthand(attrs["inner-padding"]) } : {},
1426
+ ...width !== void 0 ? { width } : {},
1427
+ align: parseAlignment(attrs.align, "center"),
1428
+ ...isNewTab(attrs) ? { openInNewTab: true } : {},
1429
+ ...base
1430
+ }),
1431
+ entry: {
1432
+ sourceTag: "mj-button",
1433
+ templaticalBlockType: "button",
1434
+ status: notes.length > 0 ? "approximated" : "converted",
1435
+ ...notes.length > 0 ? { note: notes.join(" ") } : {}
1436
+ }
1437
+ };
1021
1438
  }
1022
1439
  /**
1023
1440
  * `mj-divider`'s `width` as `DividerBlock.width`. A percentage is a share of
@@ -1111,30 +1528,8 @@ function convertElement($el, ctx) {
1111
1528
  if (tag === "mj-social") return convertSocial($el, attrs, ctx);
1112
1529
  if (tag === "mj-navbar") return convertNavbar($el, attrs, ctx);
1113
1530
  if (tag === "mj-table") return convertNativeTable($el, attrs, ctx);
1114
- if (tag === "mj-image") {
1115
- const block = convertImage($el, attrs, ctx);
1116
- if (!block) return null;
1117
- return {
1118
- block,
1119
- entry: {
1120
- sourceTag: tag,
1121
- templaticalBlockType: "image",
1122
- status: "converted"
1123
- }
1124
- };
1125
- }
1126
- if (tag === "mj-button") {
1127
- const block = convertButton($el, attrs);
1128
- if (!block) return null;
1129
- return {
1130
- block,
1131
- entry: {
1132
- sourceTag: tag,
1133
- templaticalBlockType: "button",
1134
- status: "converted"
1135
- }
1136
- };
1137
- }
1531
+ if (tag === "mj-image") return convertImage($el, attrs, ctx);
1532
+ if (tag === "mj-button") return convertButton($el, attrs, ctx);
1138
1533
  if (tag === "mj-divider") return convertDivider(attrs, ctx);
1139
1534
  if (tag === "mj-spacer") return {
1140
1535
  block: convertSpacer(attrs),
@@ -1306,7 +1701,7 @@ function extractSettings($, cascade, warnings) {
1306
1701
  const $root = findByTag($, "mjml").first();
1307
1702
  const width = parsePxValue($body.attr("width")) || REQUIRED_TEMPLATE_DEFAULTS.width;
1308
1703
  const backgroundColor = parseColor($body.attr("background-color")) || REQUIRED_TEMPLATE_DEFAULTS.backgroundColor;
1309
- const fontFromCascade = parseFontFamily(cascade.all["font-family"]) || parseFontFamily(cascade.byTag["mj-text"]?.["font-family"]);
1704
+ const fontFromCascade = canonicalFontStack(cascade.all["font-family"]) || canonicalFontStack(cascade.byTag["mj-text"]?.["font-family"]);
1310
1705
  const fontFromDeclaration = findByTag($, "mj-font").first().attr("name") ?? "";
1311
1706
  const fontFamily = fontFromCascade || fontFromDeclaration || REQUIRED_TEMPLATE_DEFAULTS.fontFamily;
1312
1707
  const textColor = parseColor(cascade.byTag["mj-text"]?.color) || REQUIRED_TEMPLATE_DEFAULTS.textColor;
@@ -1531,11 +1926,16 @@ function buildSection($el, ctx, entries, wrapper) {
1531
1926
  const slots = COLUMN_COUNT[layout];
1532
1927
  const pixels = columnPixels(layout, ctx.containerWidth);
1533
1928
  const shown = rawWidths.map((w) => w || "auto").join(", ");
1929
+ const notes = exact ? [] : [`Column widths ${shown} have no exact Templatical layout; resolved to "${layout}".`];
1930
+ const borderRadius = readBorderRadius(attrs, "Section", notes);
1931
+ const border = readBorder(attrs, "Section", notes);
1932
+ const background = readMjmlBackgroundImage((name) => attrs[name], "Section", ctx.containerWidth);
1933
+ if (background) notes.push(...background.notes);
1534
1934
  entries.push({
1535
1935
  sourceTag: "mj-section",
1536
1936
  templaticalBlockType: "section",
1537
- status: exact ? "converted" : "approximated",
1538
- ...exact ? {} : { note: `Column widths ${shown} have no exact Templatical layout; resolved to "${layout}".` }
1937
+ status: notes.length > 0 ? "approximated" : "converted",
1938
+ ...notes.length > 0 ? { note: notes.join(" ") } : {}
1539
1939
  });
1540
1940
  const children = Array.from({ length: slots }, () => []);
1541
1941
  columns.forEach(($column, index) => {
@@ -1548,24 +1948,59 @@ function buildSection($el, ctx, entries, wrapper) {
1548
1948
  };
1549
1949
  children[slot].push(...convertColumnChildren($column, columnCtx, entries));
1550
1950
  });
1551
- const borderRadius = parsePxValue(attrs["border-radius"]);
1552
1951
  return [createSectionBlock({
1553
1952
  columns: layout,
1554
1953
  children,
1555
1954
  ...grouped ? { stackOnMobile: false } : {},
1556
- ...borderRadius > 0 ? { borderRadius } : {},
1955
+ ...borderRadius ? { borderRadius } : {},
1956
+ ...border ? { border } : {},
1957
+ ...background ? { backgroundImage: background.image } : {},
1557
1958
  ...wrapper ? { wrapper } : {},
1558
1959
  ...baseFields(attrs, "native")
1559
1960
  })];
1560
1961
  }
1561
- function readWrapper(attrs) {
1962
+ const WRAPPER_BORDER_ATTRIBUTES = [
1963
+ "border",
1964
+ "border-top",
1965
+ "border-right",
1966
+ "border-bottom",
1967
+ "border-left"
1968
+ ];
1969
+ /**
1970
+ * `SectionWrapper` has no border, so each `border` or `border-<side>` the
1971
+ * wrapper sets pushes a note instead.
1972
+ */
1973
+ function readWrapper(attrs, notes, boxWidth) {
1562
1974
  const backgroundColor = parseColor(attrs["background-color"]);
1563
1975
  const padding = parsePaddingShorthand(attrs.padding);
1564
- const borderRadius = parsePxValue(attrs["border-radius"]);
1976
+ const borderRadius = readBorderRadius(attrs, "Wrapper", notes);
1977
+ const background = readMjmlBackgroundImage((name) => attrs[name], "Wrapper", boxWidth);
1978
+ for (const name of WRAPPER_BORDER_ATTRIBUTES) {
1979
+ const raw = attrs[name];
1980
+ if (raw === void 0) continue;
1981
+ notes.push(`Wrapper ${name} "${raw.trim()}" has no Templatical equivalent; dropped.`);
1982
+ }
1983
+ if (background) notes.push(...background.notes);
1565
1984
  return {
1566
1985
  ...backgroundColor ? { backgroundColor } : {},
1567
1986
  padding,
1568
- ...borderRadius > 0 ? { borderRadius } : {}
1987
+ ...borderRadius ? { borderRadius } : {},
1988
+ ...background ? { backgroundImage: background.image } : {}
1989
+ };
1990
+ }
1991
+ /**
1992
+ * Each section gets its own band object and band image, so editing one
1993
+ * section's image leaves the others alone. `padding` and `borderRadius` stay
1994
+ * shared, as in the other importers.
1995
+ */
1996
+ function copyWrapper(wrapper) {
1997
+ const image = wrapper.backgroundImage;
1998
+ return {
1999
+ ...wrapper,
2000
+ ...image ? { backgroundImage: {
2001
+ ...image,
2002
+ position: { ...image.position }
2003
+ } } : {}
1569
2004
  };
1570
2005
  }
1571
2006
  /**
@@ -1576,7 +2011,9 @@ function readWrapper(attrs) {
1576
2011
  * as its own section would double the nesting on every round trip.
1577
2012
  */
1578
2013
  function buildWrapper($el, ctx, entries) {
1579
- const wrapper = readWrapper(resolveAttributes($el, ctx.cascade));
2014
+ const attrs = resolveAttributes($el, ctx.cascade);
2015
+ const notes = [];
2016
+ const wrapper = readWrapper(attrs, notes, ctx.containerWidth);
1580
2017
  const sections = childElements($el, ctx.$).filter(($k) => tagOf($k[0]) === "mj-section");
1581
2018
  if (sections.length === 0) {
1582
2019
  entries.push({
@@ -1587,13 +2024,14 @@ function buildWrapper($el, ctx, entries) {
1587
2024
  });
1588
2025
  return [];
1589
2026
  }
1590
- if (sections.length > 1) entries.push({
2027
+ if (sections.length > 1) notes.unshift(`An <mj-wrapper> holding ${sections.length} sections was applied to each of them — Templatical has no multi-section band.`);
2028
+ if (notes.length > 0) entries.push({
1591
2029
  sourceTag: "mj-wrapper",
1592
2030
  templaticalBlockType: "section",
1593
2031
  status: "approximated",
1594
- note: `An <mj-wrapper> holding ${sections.length} sections was applied to each of them — Templatical has no multi-section band.`
2032
+ note: notes.join(" ")
1595
2033
  });
1596
- return sections.flatMap(($section) => buildSection($section, ctx, entries, wrapper));
2034
+ return sections.flatMap(($section) => buildSection($section, ctx, entries, copyWrapper(wrapper)));
1597
2035
  }
1598
2036
  //#endregion
1599
2037
  //#region src/converter.ts