@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 +513 -75
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
1538
|
-
...
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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)
|
|
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:
|
|
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
|