@templatical/import-mjml 0.46.0 → 0.48.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.d.ts +1 -1
- package/dist/index.js +442 -19
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ type ConversionStatus = "converted" | "approximated" | "html-fallback" | "skippe
|
|
|
14
14
|
* A single entry in the import report.
|
|
15
15
|
*/
|
|
16
16
|
interface ImportReportEntry {
|
|
17
|
-
/** The source MJML tag name (e.g. "mj-section", "mj-image", "mj-
|
|
17
|
+
/** The source MJML tag name (e.g. "mj-section", "mj-image", "mj-carousel"). */
|
|
18
18
|
sourceTag: string;
|
|
19
19
|
/** The Templatical block type produced, or null if skipped. */
|
|
20
20
|
templaticalBlockType: string | null;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { load } from "cheerio";
|
|
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";
|
|
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, resolveHero } from "@templatical/types";
|
|
3
3
|
import { isTag, isText } from "domhandler";
|
|
4
4
|
//#region src/attribute-resolver.ts
|
|
5
5
|
/**
|
|
@@ -470,6 +470,280 @@ function parseBorderStyle(value) {
|
|
|
470
470
|
if (v === "dashed" || v === "dotted") return v;
|
|
471
471
|
return "solid";
|
|
472
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
|
+
}
|
|
664
|
+
const HERO_ALIGNS = [
|
|
665
|
+
"top",
|
|
666
|
+
"middle",
|
|
667
|
+
"bottom"
|
|
668
|
+
];
|
|
669
|
+
const HERO_DROPPED_ATTRIBUTES = [
|
|
670
|
+
"inner-padding",
|
|
671
|
+
"inner-padding-top",
|
|
672
|
+
"inner-padding-right",
|
|
673
|
+
"inner-padding-bottom",
|
|
674
|
+
"inner-padding-left",
|
|
675
|
+
"inner-background-color"
|
|
676
|
+
];
|
|
677
|
+
/**
|
|
678
|
+
* An `mj-hero`'s mode, height and background, taking MJML's defaults for what
|
|
679
|
+
* it leaves out (`fixed-height`, `0px`, `center center`, `vertical-align:
|
|
680
|
+
* top`). mj-hero always paints its image `cover` and unrepeated and has no
|
|
681
|
+
* position longhands, so those are fixed rather than read.
|
|
682
|
+
* `background-width` / `background-height` are stored as the image's
|
|
683
|
+
* `naturalWidth` / `naturalHeight`, though they are often the hero box's size
|
|
684
|
+
* (Templatical's renderer writes them so) rather than the file's: only their
|
|
685
|
+
* ratio is used. A `fixed-height` hero without a positive `height` is as
|
|
686
|
+
* tall as its content — exactly what a regular section with the same
|
|
687
|
+
* background paints — so it comes back without `hero`.
|
|
688
|
+
*
|
|
689
|
+
* Templatical's renderer writes "match image" as `fixed-height`: the image
|
|
690
|
+
* drawn across the hero box (`background-width` = `boxWidth`) and the box at
|
|
691
|
+
* the image's own height, or at the hero's `verticalPadding` where the image
|
|
692
|
+
* is shorter. That exact shape comes back as `height: "image"`, which
|
|
693
|
+
* re-renders identically.
|
|
694
|
+
*/
|
|
695
|
+
function readMjmlHero(read, label, boxWidth, verticalPadding) {
|
|
696
|
+
const notes = [];
|
|
697
|
+
const background = readMjmlBackgroundImage((name) => {
|
|
698
|
+
if (name === "background-size") return "cover";
|
|
699
|
+
if (name === "background-repeat") return "no-repeat";
|
|
700
|
+
if (name === "background-position") return read(name) ?? "center center";
|
|
701
|
+
if (name === "background-position-x" || name === "background-position-y") return;
|
|
702
|
+
return read(name);
|
|
703
|
+
}, label, boxWidth);
|
|
704
|
+
if (background) notes.push(...background.notes);
|
|
705
|
+
const width = parseDefinitePx(read("background-width"));
|
|
706
|
+
const height = parseDefinitePx(read("background-height"));
|
|
707
|
+
const sized = width !== null && height !== null && width > 0 && height > 0;
|
|
708
|
+
const image = background ? {
|
|
709
|
+
...background.image,
|
|
710
|
+
...sized ? {
|
|
711
|
+
naturalWidth: width,
|
|
712
|
+
naturalHeight: height
|
|
713
|
+
} : {}
|
|
714
|
+
} : null;
|
|
715
|
+
const rawAlign = read("vertical-align")?.trim();
|
|
716
|
+
const verticalAlign = HERO_ALIGNS.find((align) => align === rawAlign?.toLowerCase()) ?? "top";
|
|
717
|
+
if (rawAlign && verticalAlign !== rawAlign.toLowerCase()) notes.push(`${label} vertical-align "${rawAlign}" has no Templatical equivalent; imported as "top".`);
|
|
718
|
+
const rawMode = read("mode")?.trim();
|
|
719
|
+
const mode = rawMode ? rawMode.toLowerCase() : "fixed-height";
|
|
720
|
+
let hero = null;
|
|
721
|
+
if (mode === "fluid-height") {
|
|
722
|
+
hero = {
|
|
723
|
+
height: "image",
|
|
724
|
+
verticalAlign
|
|
725
|
+
};
|
|
726
|
+
if (!sized) notes.push(`${label} fluid-height has no background-width / background-height, so its image size is unknown; it renders as a regular section until it has a size or a fixed height.`);
|
|
727
|
+
} else {
|
|
728
|
+
if (mode !== "fixed-height") notes.push(`${label} mode "${rawMode}" has no Templatical equivalent; imported as "fixed-height".`);
|
|
729
|
+
const fixed = parseDefinitePx(read("height"));
|
|
730
|
+
if (fixed !== null && fixed > 0) hero = {
|
|
731
|
+
height: sized && width === boxWidth && fixed === Math.max(height, verticalPadding) ? "image" : Math.round(fixed),
|
|
732
|
+
verticalAlign
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (hero && !image) notes.push(`${label} has no background-url, so it renders as a regular section until it has a background image.`);
|
|
736
|
+
for (const name of HERO_DROPPED_ATTRIBUTES) {
|
|
737
|
+
const raw = read(name);
|
|
738
|
+
if (raw === void 0) continue;
|
|
739
|
+
notes.push(`${label} ${name} "${raw.trim()}" has no Templatical equivalent; dropped.`);
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
hero,
|
|
743
|
+
image,
|
|
744
|
+
notes
|
|
745
|
+
};
|
|
746
|
+
}
|
|
473
747
|
//#endregion
|
|
474
748
|
//#region src/block-base.ts
|
|
475
749
|
/**
|
|
@@ -501,6 +775,63 @@ function baseFields(attrs, placement = "container") {
|
|
|
501
775
|
};
|
|
502
776
|
}
|
|
503
777
|
/**
|
|
778
|
+
* The width side padding and side borders take from a box, read from the
|
|
779
|
+
* element's own attributes exactly as MJML reads them when it sizes the box
|
|
780
|
+
* (`getBoxWidths`): `padding-left` / `-right` over the `padding` shorthand and
|
|
781
|
+
* `border-left` / `-right` over `border`, each as a whole pixel (`parseInt`).
|
|
782
|
+
* Read from the source rather than the converted block, whose padding and
|
|
783
|
+
* border widths are rounded, so a room matches MJML's to the pixel.
|
|
784
|
+
*/
|
|
785
|
+
function mjmlHorizontalInset(attrs) {
|
|
786
|
+
const border = (side) => {
|
|
787
|
+
const raw = attrs[`border-${side}`] || attrs.border || "0";
|
|
788
|
+
const width = /(?:^| )(\d+)/.exec(raw);
|
|
789
|
+
return width ? parseInt(width[1], 10) : 0;
|
|
790
|
+
};
|
|
791
|
+
return mjmlPaddingSide(attrs, "left") + mjmlPaddingSide(attrs, "right") + border("left") + border("right");
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Top plus bottom padding, read from the element's own attributes exactly as
|
|
795
|
+
* MJML reads them when it sizes a fixed-height hero's cell: `padding-top` /
|
|
796
|
+
* `-bottom` over the `padding` shorthand, each as a whole pixel.
|
|
797
|
+
*/
|
|
798
|
+
function mjmlVerticalPadding(attrs) {
|
|
799
|
+
return mjmlPaddingSide(attrs, "top") + mjmlPaddingSide(attrs, "bottom");
|
|
800
|
+
}
|
|
801
|
+
/** One side's padding as MJML's `getShorthandAttrValue` reads it. */
|
|
802
|
+
function mjmlPaddingSide(attrs, side) {
|
|
803
|
+
const own = attrs[`padding-${side}`];
|
|
804
|
+
if (own) return parseInt(own, 10) || 0;
|
|
805
|
+
return attrs.padding ? shorthandSide(attrs.padding, side) : 0;
|
|
806
|
+
}
|
|
807
|
+
/** Which value of a 2-, 3- or 4-value shorthand each side takes. */
|
|
808
|
+
const SHORTHAND_INDEX = {
|
|
809
|
+
2: {
|
|
810
|
+
top: 0,
|
|
811
|
+
right: 1,
|
|
812
|
+
bottom: 0,
|
|
813
|
+
left: 1
|
|
814
|
+
},
|
|
815
|
+
3: {
|
|
816
|
+
top: 0,
|
|
817
|
+
right: 1,
|
|
818
|
+
bottom: 2,
|
|
819
|
+
left: 1
|
|
820
|
+
},
|
|
821
|
+
4: {
|
|
822
|
+
top: 0,
|
|
823
|
+
right: 1,
|
|
824
|
+
bottom: 2,
|
|
825
|
+
left: 3
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
/** One side of a CSS padding shorthand, as MJML's `shorthandParser` reads it. */
|
|
829
|
+
function shorthandSide(value, side) {
|
|
830
|
+
const parts = value.trim().replace(/\s+/g, " ").split(" ", 4);
|
|
831
|
+
const index = SHORTHAND_INDEX[parts.length]?.[side];
|
|
832
|
+
return parseInt(index === void 0 ? value : parts[index], 10) || 0;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
504
835
|
* `border-radius` as a `BorderRadiusValue`, `undefined` when the attribute is
|
|
505
836
|
* absent. A value with no px reading is square corners, `0`, and pushes a
|
|
506
837
|
* note: `label` names the element in it.
|
|
@@ -1134,11 +1465,7 @@ const STRUCTURAL_TAGS = /* @__PURE__ */ new Set([
|
|
|
1134
1465
|
"mj-navbar-link"
|
|
1135
1466
|
]);
|
|
1136
1467
|
/** Tags with no Templatical equivalent that keep their markup verbatim. */
|
|
1137
|
-
const NO_EQUIVALENT_TAGS = /* @__PURE__ */ new Set([
|
|
1138
|
-
"mj-hero",
|
|
1139
|
-
"mj-carousel",
|
|
1140
|
-
"mj-accordion"
|
|
1141
|
-
]);
|
|
1468
|
+
const NO_EQUIVALENT_TAGS = /* @__PURE__ */ new Set(["mj-carousel", "mj-accordion"]);
|
|
1142
1469
|
function convertImage($el, attrs, ctx) {
|
|
1143
1470
|
const src = (attrs.src ?? "").trim();
|
|
1144
1471
|
if (!src) return null;
|
|
@@ -1149,11 +1476,15 @@ function convertImage($el, attrs, ctx) {
|
|
|
1149
1476
|
const notes = [];
|
|
1150
1477
|
const borderRadius = readBorderRadius(attrs, "Image", notes);
|
|
1151
1478
|
const border = readBorder(attrs, "Image", notes);
|
|
1479
|
+
const base = baseFields(attrs);
|
|
1480
|
+
const room = Math.floor(ctx.contentWidth) - mjmlHorizontalInset(attrs);
|
|
1481
|
+
const width = !pxWidth || pxWidth >= room ? "full" : pxWidth;
|
|
1482
|
+
if ((attrs["fluid-on-mobile"] ?? "").trim().toLowerCase() === "true" && width !== "full") notes.push(`fluid-on-mobile on a ${width}px image narrower than its column has no Templatical equivalent: the image keeps its width on phones instead of filling the column.`);
|
|
1152
1483
|
return {
|
|
1153
1484
|
block: createImageBlock({
|
|
1154
1485
|
src,
|
|
1155
1486
|
alt: decorative ? "" : attrs.alt ?? "",
|
|
1156
|
-
width
|
|
1487
|
+
width,
|
|
1157
1488
|
align: parseAlignment(attrs.align, "center"),
|
|
1158
1489
|
...height > 0 ? { height } : {},
|
|
1159
1490
|
...borderRadius ? { borderRadius } : {},
|
|
@@ -1161,7 +1492,7 @@ function convertImage($el, attrs, ctx) {
|
|
|
1161
1492
|
...href ? { linkUrl: href } : {},
|
|
1162
1493
|
...href && isNewTab(attrs) ? { linkOpenInNewTab: true } : {},
|
|
1163
1494
|
...decorative ? { decorative: true } : {},
|
|
1164
|
-
...
|
|
1495
|
+
...base
|
|
1165
1496
|
}),
|
|
1166
1497
|
entry: {
|
|
1167
1498
|
sourceTag: "mj-image",
|
|
@@ -1630,6 +1961,22 @@ const COLUMN_COUNT = {
|
|
|
1630
1961
|
"2-1": 2,
|
|
1631
1962
|
"1-2": 2
|
|
1632
1963
|
};
|
|
1964
|
+
/**
|
|
1965
|
+
* Column percentages per layout, exactly as `getWidthPercentages` in
|
|
1966
|
+
* `renderer/src/columns.ts` emits them. MJML sizes a column from the emitted
|
|
1967
|
+
* string, so 33.33% of 600 is 199, not a third.
|
|
1968
|
+
*/
|
|
1969
|
+
const COLUMN_PERCENTS = {
|
|
1970
|
+
"1": [100],
|
|
1971
|
+
"2": [50, 50],
|
|
1972
|
+
"3": [
|
|
1973
|
+
33.33,
|
|
1974
|
+
33.33,
|
|
1975
|
+
33.34
|
|
1976
|
+
],
|
|
1977
|
+
"1-2": [33.33, 66.67],
|
|
1978
|
+
"2-1": [66.67, 33.33]
|
|
1979
|
+
};
|
|
1633
1980
|
/** Column pixel widths per layout, mirroring `renderer/src/columns.ts`. */
|
|
1634
1981
|
function columnPixels(layout, containerWidth) {
|
|
1635
1982
|
switch (layout) {
|
|
@@ -1683,12 +2030,12 @@ function convertColumnChildren($column, ctx, entries) {
|
|
|
1683
2030
|
const blocks = [];
|
|
1684
2031
|
for (const unit of planSiblings(childElements($column, ctx.$))) {
|
|
1685
2032
|
const tag = tagOf(unit.$el[0]);
|
|
1686
|
-
if (tag === "mj-section" || tag === "mj-wrapper") {
|
|
2033
|
+
if (tag === "mj-section" || tag === "mj-wrapper" || tag === "mj-hero") {
|
|
1687
2034
|
const attrs = resolveAttributes(unit.$el, ctx.cascade);
|
|
1688
2035
|
const fallback = convertHtmlFallback(unit.$el, ctx, attrs);
|
|
1689
2036
|
if (unit.displayCondition) fallback.displayCondition = unit.displayCondition;
|
|
1690
2037
|
blocks.push(fallback);
|
|
1691
|
-
const noun = tag === "mj-section" ? "section" : "wrapper";
|
|
2038
|
+
const noun = tag === "mj-section" ? "section" : tag === "mj-hero" ? "hero" : "wrapper";
|
|
1692
2039
|
entries.push({
|
|
1693
2040
|
sourceTag: tag,
|
|
1694
2041
|
templaticalBlockType: "html",
|
|
@@ -1738,6 +2085,10 @@ function buildSection($el, ctx, entries, wrapper) {
|
|
|
1738
2085
|
const notes = exact ? [] : [`Column widths ${shown} have no exact Templatical layout; resolved to "${layout}".`];
|
|
1739
2086
|
const borderRadius = readBorderRadius(attrs, "Section", notes);
|
|
1740
2087
|
const border = readBorder(attrs, "Section", notes);
|
|
2088
|
+
const background = readMjmlBackgroundImage((name) => attrs[name], "Section", ctx.containerWidth);
|
|
2089
|
+
if (background) notes.push(...background.notes);
|
|
2090
|
+
const sectionFields = baseFields(attrs, "native");
|
|
2091
|
+
const sectionBox = Math.floor(ctx.contentWidth) - mjmlHorizontalInset(attrs);
|
|
1741
2092
|
entries.push({
|
|
1742
2093
|
sourceTag: "mj-section",
|
|
1743
2094
|
templaticalBlockType: "section",
|
|
@@ -1751,6 +2102,7 @@ function buildSection($el, ctx, entries, wrapper) {
|
|
|
1751
2102
|
const columnCtx = {
|
|
1752
2103
|
...ctx,
|
|
1753
2104
|
containerWidth: Math.round(pixels[slot] ?? ctx.containerWidth),
|
|
2105
|
+
contentWidth: Math.floor(sectionBox * COLUMN_PERCENTS[layout][slot] / 100),
|
|
1754
2106
|
columnPadding: left + right
|
|
1755
2107
|
};
|
|
1756
2108
|
children[slot].push(...convertColumnChildren($column, columnCtx, entries));
|
|
@@ -1761,8 +2113,53 @@ function buildSection($el, ctx, entries, wrapper) {
|
|
|
1761
2113
|
...grouped ? { stackOnMobile: false } : {},
|
|
1762
2114
|
...borderRadius ? { borderRadius } : {},
|
|
1763
2115
|
...border ? { border } : {},
|
|
2116
|
+
...background ? { backgroundImage: background.image } : {},
|
|
2117
|
+
...wrapper ? { wrapper } : {},
|
|
2118
|
+
...sectionFields
|
|
2119
|
+
})];
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Build a one-column `SectionBlock` from an `mj-hero`, whose content elements
|
|
2123
|
+
* sit directly under it (no `mj-column`). Its mode, height and background map
|
|
2124
|
+
* onto `hero` and `backgroundImage` (`readMjmlHero`). mj-hero paints white
|
|
2125
|
+
* where its source sets no `background-color`, so an absent colour imports as
|
|
2126
|
+
* `#ffffff`; an explicit `transparent` stays unset.
|
|
2127
|
+
*/
|
|
2128
|
+
function buildHero($el, ctx, entries, wrapper) {
|
|
2129
|
+
const attrs = resolveAttributes($el, ctx.cascade);
|
|
2130
|
+
const notes = [];
|
|
2131
|
+
const heroBox = Math.floor(ctx.contentWidth);
|
|
2132
|
+
const read = readMjmlHero((name) => attrs[name], "Hero", heroBox, mjmlVerticalPadding(attrs));
|
|
2133
|
+
notes.push(...read.notes);
|
|
2134
|
+
const borderRadius = readBorderRadius(attrs, "Hero", notes);
|
|
2135
|
+
entries.push({
|
|
2136
|
+
sourceTag: "mj-hero",
|
|
2137
|
+
templaticalBlockType: "section",
|
|
2138
|
+
status: notes.length > 0 ? "approximated" : "converted",
|
|
2139
|
+
...notes.length > 0 ? { note: notes.join(" ") } : {}
|
|
2140
|
+
});
|
|
2141
|
+
const fields = baseFields(attrs, "native");
|
|
2142
|
+
const heroPadding = mjmlHorizontalInset(attrs);
|
|
2143
|
+
const applied = resolveHero({
|
|
2144
|
+
columns: "1",
|
|
2145
|
+
hero: read.hero ?? void 0,
|
|
2146
|
+
backgroundImage: read.image ?? void 0
|
|
2147
|
+
})?.applied;
|
|
2148
|
+
const children = convertColumnChildren($el, {
|
|
2149
|
+
...ctx,
|
|
2150
|
+
containerWidth: applied ? heroBox : ctx.containerWidth,
|
|
2151
|
+
contentWidth: heroBox - heroPadding,
|
|
2152
|
+
columnPadding: heroPadding
|
|
2153
|
+
}, entries);
|
|
2154
|
+
if (attrs["background-color"] === void 0) fields.styles.backgroundColor = "#ffffff";
|
|
2155
|
+
return [createSectionBlock({
|
|
2156
|
+
columns: "1",
|
|
2157
|
+
children: [children],
|
|
2158
|
+
...borderRadius ? { borderRadius } : {},
|
|
2159
|
+
...read.image ? { backgroundImage: read.image } : {},
|
|
2160
|
+
...read.hero ? { hero: read.hero } : {},
|
|
1764
2161
|
...wrapper ? { wrapper } : {},
|
|
1765
|
-
...
|
|
2162
|
+
...fields
|
|
1766
2163
|
})];
|
|
1767
2164
|
}
|
|
1768
2165
|
const WRAPPER_BORDER_ATTRIBUTES = [
|
|
@@ -1776,19 +2173,37 @@ const WRAPPER_BORDER_ATTRIBUTES = [
|
|
|
1776
2173
|
* `SectionWrapper` has no border, so each `border` or `border-<side>` the
|
|
1777
2174
|
* wrapper sets pushes a note instead.
|
|
1778
2175
|
*/
|
|
1779
|
-
function readWrapper(attrs, notes) {
|
|
2176
|
+
function readWrapper(attrs, notes, boxWidth) {
|
|
1780
2177
|
const backgroundColor = parseColor(attrs["background-color"]);
|
|
1781
2178
|
const padding = parsePaddingShorthand(attrs.padding);
|
|
1782
2179
|
const borderRadius = readBorderRadius(attrs, "Wrapper", notes);
|
|
2180
|
+
const background = readMjmlBackgroundImage((name) => attrs[name], "Wrapper", boxWidth);
|
|
1783
2181
|
for (const name of WRAPPER_BORDER_ATTRIBUTES) {
|
|
1784
2182
|
const raw = attrs[name];
|
|
1785
2183
|
if (raw === void 0) continue;
|
|
1786
2184
|
notes.push(`Wrapper ${name} "${raw.trim()}" has no Templatical equivalent; dropped.`);
|
|
1787
2185
|
}
|
|
2186
|
+
if (background) notes.push(...background.notes);
|
|
1788
2187
|
return {
|
|
1789
2188
|
...backgroundColor ? { backgroundColor } : {},
|
|
1790
2189
|
padding,
|
|
1791
|
-
...borderRadius ? { borderRadius } : {}
|
|
2190
|
+
...borderRadius ? { borderRadius } : {},
|
|
2191
|
+
...background ? { backgroundImage: background.image } : {}
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Each section gets its own band object and band image, so editing one
|
|
2196
|
+
* section's image leaves the others alone. `padding` and `borderRadius` stay
|
|
2197
|
+
* shared, as in the other importers.
|
|
2198
|
+
*/
|
|
2199
|
+
function copyWrapper(wrapper) {
|
|
2200
|
+
const image = wrapper.backgroundImage;
|
|
2201
|
+
return {
|
|
2202
|
+
...wrapper,
|
|
2203
|
+
...image ? { backgroundImage: {
|
|
2204
|
+
...image,
|
|
2205
|
+
position: { ...image.position }
|
|
2206
|
+
} } : {}
|
|
1792
2207
|
};
|
|
1793
2208
|
}
|
|
1794
2209
|
/**
|
|
@@ -1801,14 +2216,17 @@ function readWrapper(attrs, notes) {
|
|
|
1801
2216
|
function buildWrapper($el, ctx, entries) {
|
|
1802
2217
|
const attrs = resolveAttributes($el, ctx.cascade);
|
|
1803
2218
|
const notes = [];
|
|
1804
|
-
const wrapper = readWrapper(attrs, notes);
|
|
1805
|
-
const sections = childElements($el, ctx.$).filter(($k) =>
|
|
2219
|
+
const wrapper = readWrapper(attrs, notes, ctx.containerWidth);
|
|
2220
|
+
const sections = childElements($el, ctx.$).filter(($k) => {
|
|
2221
|
+
const tag = tagOf($k[0]);
|
|
2222
|
+
return tag === "mj-section" || tag === "mj-hero";
|
|
2223
|
+
});
|
|
1806
2224
|
if (sections.length === 0) {
|
|
1807
2225
|
entries.push({
|
|
1808
2226
|
sourceTag: "mj-wrapper",
|
|
1809
2227
|
templaticalBlockType: null,
|
|
1810
2228
|
status: "skipped",
|
|
1811
|
-
note: "An <mj-wrapper> with no <mj-section> children produces nothing."
|
|
2229
|
+
note: "An <mj-wrapper> with no <mj-section> or <mj-hero> children produces nothing."
|
|
1812
2230
|
});
|
|
1813
2231
|
return [];
|
|
1814
2232
|
}
|
|
@@ -1819,7 +2237,11 @@ function buildWrapper($el, ctx, entries) {
|
|
|
1819
2237
|
status: "approximated",
|
|
1820
2238
|
note: notes.join(" ")
|
|
1821
2239
|
});
|
|
1822
|
-
|
|
2240
|
+
const bandCtx = {
|
|
2241
|
+
...ctx,
|
|
2242
|
+
contentWidth: Math.floor(ctx.contentWidth) - mjmlHorizontalInset(attrs)
|
|
2243
|
+
};
|
|
2244
|
+
return sections.flatMap(($section) => tagOf($section[0]) === "mj-hero" ? buildHero($section, bandCtx, entries, copyWrapper(wrapper)) : buildSection($section, bandCtx, entries, copyWrapper(wrapper)));
|
|
1823
2245
|
}
|
|
1824
2246
|
//#endregion
|
|
1825
2247
|
//#region src/converter.ts
|
|
@@ -1854,9 +2276,9 @@ function walkBody($body, ctx, entries) {
|
|
|
1854
2276
|
};
|
|
1855
2277
|
for (const unit of planSiblings(childElements($body, ctx.$))) {
|
|
1856
2278
|
const tag = tagOf(unit.$el[0]);
|
|
1857
|
-
if (tag === "mj-wrapper" || tag === "mj-section") {
|
|
2279
|
+
if (tag === "mj-wrapper" || tag === "mj-section" || tag === "mj-hero") {
|
|
1858
2280
|
flushLoose();
|
|
1859
|
-
const produced = tag === "mj-wrapper" ? buildWrapper(unit.$el, ctx, entries) : buildSection(unit.$el, ctx, entries);
|
|
2281
|
+
const produced = tag === "mj-wrapper" ? buildWrapper(unit.$el, ctx, entries) : tag === "mj-hero" ? buildHero(unit.$el, ctx, entries) : buildSection(unit.$el, ctx, entries);
|
|
1860
2282
|
for (const block of produced) {
|
|
1861
2283
|
if (unit.displayCondition) block.displayCondition = unit.displayCondition;
|
|
1862
2284
|
blocks.push(block);
|
|
@@ -1907,6 +2329,7 @@ function convertMjmlTemplate(mjml) {
|
|
|
1907
2329
|
$,
|
|
1908
2330
|
cascade,
|
|
1909
2331
|
containerWidth: settings.width,
|
|
2332
|
+
contentWidth: settings.width,
|
|
1910
2333
|
columnPadding: 0,
|
|
1911
2334
|
direction: resolveContentDirection(settings),
|
|
1912
2335
|
warnings
|