@templatical/import-html 0.33.0 → 0.34.1
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 +1091 -81
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { load } from "cheerio";
|
|
2
2
|
import { createButtonBlock, createDefaultTemplateContent, createDividerBlock, createHtmlBlock, createImageBlock, createParagraphBlock, createSectionBlock, createSpacerBlock, createTitleBlock } from "@templatical/types";
|
|
3
|
+
import { isTag, isText } from "domhandler";
|
|
3
4
|
//#region src/style-parser.ts
|
|
4
5
|
/**
|
|
5
6
|
* Parses a CSS `style="..."` attribute string into a flat key/value record.
|
|
@@ -385,6 +386,147 @@ const TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
|
385
386
|
"span",
|
|
386
387
|
"div"
|
|
387
388
|
]);
|
|
389
|
+
/**
|
|
390
|
+
* Wrapper tags that carry layout rather than content. One of these is worth
|
|
391
|
+
* descending into when it holds a table somewhere below it.
|
|
392
|
+
*/
|
|
393
|
+
const CONTAINER_TAGS = /* @__PURE__ */ new Set([
|
|
394
|
+
"div",
|
|
395
|
+
"center",
|
|
396
|
+
"main"
|
|
397
|
+
]);
|
|
398
|
+
/**
|
|
399
|
+
* Decides whether an element is a layout container worth descending into: a
|
|
400
|
+
* wrapper tag that holds a table somewhere below it.
|
|
401
|
+
*
|
|
402
|
+
* The table test is what keeps the descent from widening into "descend every
|
|
403
|
+
* div". `div` is in `TEXT_TAGS` above, so a container holding only copy must
|
|
404
|
+
* keep that mapping and become one paragraph rather than being split into a
|
|
405
|
+
* block per child.
|
|
406
|
+
*
|
|
407
|
+
* Lives here, with the other predicates both traversal modules consult,
|
|
408
|
+
* because the body walk and the cell walk have to agree on what a container
|
|
409
|
+
* is. A second copy answers the question differently the moment either is
|
|
410
|
+
* edited, and the divergence shows up as a table swallowed into a paragraph
|
|
411
|
+
* on whichever surface was missed.
|
|
412
|
+
*/
|
|
413
|
+
function isTableContainer($el, tag) {
|
|
414
|
+
return CONTAINER_TAGS.has(tag) && $el.find("table").length > 0;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Whether an element is laid out beside its siblings rather than stacked above
|
|
418
|
+
* them: `display: inline-block`.
|
|
419
|
+
*
|
|
420
|
+
* This is the property that *makes* a set of divs a set of columns — an email
|
|
421
|
+
* that wants side-by-side divs has no other way to say so, since a block-level
|
|
422
|
+
* div stacks — so requiring it is the definition of the shape and not a
|
|
423
|
+
* heuristic about it. Two plain divs stack vertically, and reading those as
|
|
424
|
+
* columns would invent a layout the source never stated.
|
|
425
|
+
*/
|
|
426
|
+
function isSideBySide($el) {
|
|
427
|
+
return (getStyles$1($el).display ?? "").trim().toLowerCase() === "inline-block";
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The sibling containers that make up a cell's column set, or `null` when the
|
|
431
|
+
* cell is not one.
|
|
432
|
+
*
|
|
433
|
+
* This is the one column shape with no cell count to read: a single `<td>`
|
|
434
|
+
* holding one inline-block `<div>` per column. Cerberus's hybrid template
|
|
435
|
+
* states four layouts this way and compiled MJML states every one of them —
|
|
436
|
+
* mjml puts a whole section's columns into one cell as sibling
|
|
437
|
+
* `div.mj-column-per-*` — so a row's cell count reports one column for a
|
|
438
|
+
* layout that has two or three. Counting the divs is still *counting*: the
|
|
439
|
+
* number of columns comes from the number of elements, and a width is only
|
|
440
|
+
* ever consulted afterwards to choose between layouts of that same count.
|
|
441
|
+
*
|
|
442
|
+
* Four conditions, each of them a hazard a relaxed version would reintroduce:
|
|
443
|
+
*
|
|
444
|
+
* - Every element child must be a container. A cell mixing a column set with
|
|
445
|
+
* anything else has no column any other element belongs to.
|
|
446
|
+
* - Every one must be laid out side by side (`isSideBySide`), which is what
|
|
447
|
+
* distinguishes columns from stacked content.
|
|
448
|
+
* - None may be blank. A multi-column section with an empty slot is worse
|
|
449
|
+
* than the single column a cell count already gives, and the source's own
|
|
450
|
+
* spacer chrome is exactly what would fill one.
|
|
451
|
+
* - No text of the cell's own may survive, for the same reason as the first
|
|
452
|
+
* condition — a bare sentence beside the columns belongs to none of them.
|
|
453
|
+
*
|
|
454
|
+
* Deliberately structural: it reads no width, so a column set with no declared
|
|
455
|
+
* width still becomes one, and a `width:100%` — which every real column div
|
|
456
|
+
* carries — can never make or break the decision.
|
|
457
|
+
*/
|
|
458
|
+
function columnDivsOf($cell, $) {
|
|
459
|
+
const columns = [];
|
|
460
|
+
let inlineText = "";
|
|
461
|
+
for (const node of $cell.contents().toArray()) {
|
|
462
|
+
if (isInlineContent(node)) {
|
|
463
|
+
inlineText += $(node).text();
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (!isTag(node)) continue;
|
|
467
|
+
const tag = node.tagName.toLowerCase();
|
|
468
|
+
if (!CONTAINER_TAGS.has(tag)) return null;
|
|
469
|
+
const $child = $(node);
|
|
470
|
+
if (!isSideBySide($child)) return null;
|
|
471
|
+
if (isBlankCell($child)) return null;
|
|
472
|
+
columns.push($child);
|
|
473
|
+
}
|
|
474
|
+
if (columns.length < 2) return null;
|
|
475
|
+
if (inlineText.trim() !== "") return null;
|
|
476
|
+
return columns;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Block-level elements a container may be unwrapped down to. Only a heading:
|
|
480
|
+
* a container wrapping one is the single case where mapping the container
|
|
481
|
+
* gets the block's *type* wrong.
|
|
482
|
+
*
|
|
483
|
+
* A tag qualifies only if unwrapping it loses nothing, and `p` is the one that
|
|
484
|
+
* looks like it belongs and does not. `convertParagraph` reads an element's
|
|
485
|
+
* inner HTML, so unwrapping `<div><p class="lead">…</p></div>` drops the `<p>`
|
|
486
|
+
* and every attribute on it, where mapping the container keeps that markup
|
|
487
|
+
* inside the paragraph's content. Nothing is bought in exchange: a wrapped
|
|
488
|
+
* `<p>` already maps to a paragraph, which is the right type. A heading has no
|
|
489
|
+
* such cost, because `convertHeading` stores the level and the inner HTML —
|
|
490
|
+
* so unwrapping routes it to the same converter a bare `<h3>` already reaches.
|
|
491
|
+
*
|
|
492
|
+
* `img`, `hr` and `table` stay out for the same "nothing to fix" reason: a
|
|
493
|
+
* table belongs to the container descent the two traversals run, and the other
|
|
494
|
+
* two would widen a heading-typing rule into image and divider mapping.
|
|
495
|
+
*
|
|
496
|
+
* The set is also what keeps the styling below correct — `convertHeading` is
|
|
497
|
+
* the only converter reached with a container's styles, so admitting a tag it
|
|
498
|
+
* does not handle would silently drop them.
|
|
499
|
+
*/
|
|
500
|
+
const UNWRAPPABLE_BLOCK_TAGS = HEADING_TAGS;
|
|
501
|
+
/**
|
|
502
|
+
* Inline formatting tags, which carry no block of their own. One of these
|
|
503
|
+
* reaching a block position means the parent's text extraction stopped short —
|
|
504
|
+
* it does not mean the element has no mapping, so it must never fall through
|
|
505
|
+
* to the html-fallback arm.
|
|
506
|
+
*
|
|
507
|
+
* The hazard that keeps them listed here: a cell's inline markup and the bare
|
|
508
|
+
* text nodes around it are one run of rich text. Dispatching an inline element
|
|
509
|
+
* on its own emits a block whose entire content is `<br>` AND deletes every
|
|
510
|
+
* text node beside it, because a walk over element children never visits
|
|
511
|
+
* those. That is silent content loss — text visible in the source email never
|
|
512
|
+
* reaches the template.
|
|
513
|
+
*
|
|
514
|
+
* `a` is excluded on purpose: whether an anchor belongs to a run depends on
|
|
515
|
+
* how the source styled it, so the cell walk asks `isProseAnchor` per anchor
|
|
516
|
+
* instead. Reading every `<a>` as inline here would fold a styled call to
|
|
517
|
+
* action into the sentence beside it and lose the button.
|
|
518
|
+
*/
|
|
519
|
+
const INLINE_FORMATTING_TAGS = /* @__PURE__ */ new Set([
|
|
520
|
+
"br",
|
|
521
|
+
"em",
|
|
522
|
+
"strong",
|
|
523
|
+
"i",
|
|
524
|
+
"b",
|
|
525
|
+
"u",
|
|
526
|
+
"small",
|
|
527
|
+
"sub",
|
|
528
|
+
"sup"
|
|
529
|
+
]);
|
|
388
530
|
function emptyPadding$2() {
|
|
389
531
|
return {
|
|
390
532
|
top: 0,
|
|
@@ -401,6 +543,124 @@ function getStyles$1($el) {
|
|
|
401
543
|
return parseStyleAttribute($el.attr("style"));
|
|
402
544
|
}
|
|
403
545
|
/**
|
|
546
|
+
* Whether an element carries anything a reader would see: text, or an element
|
|
547
|
+
* that renders without text of its own.
|
|
548
|
+
*
|
|
549
|
+
* One rule with two readers, which is the point: it decides both whether a
|
|
550
|
+
* text container is worth a block at all and whether a wrapper is worth
|
|
551
|
+
* unwrapping. Two copies would let `<div><h3></h3></div>` be skipped by one
|
|
552
|
+
* and turned into an empty title by the other.
|
|
553
|
+
*/
|
|
554
|
+
function hasRenderedContent($el) {
|
|
555
|
+
if (($el.text() ?? "").trim() !== "") return true;
|
|
556
|
+
return $el.find("img, a").length > 0;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* The single element a container's whole content consists of, or `null` when
|
|
560
|
+
* the container holds anything else.
|
|
561
|
+
*
|
|
562
|
+
* Whitespace and comments are incidental — the same reading `extractContentBlocks`
|
|
563
|
+
* gives them — and `trim` counts ` ` among them, which is how the rest of
|
|
564
|
+
* this module reads it (`normalizeCellText`, and through it `isBlankCell`).
|
|
565
|
+
* Everything else is content: a second element, a bare word, or a rendering
|
|
566
|
+
* `<br>` all mean the container holds more than one thing, and unwrapping it
|
|
567
|
+
* would drop whatever was not unwrapped.
|
|
568
|
+
*/
|
|
569
|
+
function soleElementChild($el) {
|
|
570
|
+
let found = null;
|
|
571
|
+
for (const node of $el.contents().toArray()) {
|
|
572
|
+
if (isText(node)) {
|
|
573
|
+
if (node.data.trim() !== "") return null;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (!isTag(node)) continue;
|
|
577
|
+
if (found) return null;
|
|
578
|
+
found = node;
|
|
579
|
+
}
|
|
580
|
+
return found;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* The styles the innermost element of a wrapper chain renders with: each
|
|
584
|
+
* container's own declarations, overridden by those of the element inside it.
|
|
585
|
+
*
|
|
586
|
+
* The container's styles have to travel, because the wrapper is where
|
|
587
|
+
* table-based email puts the colour, size, font and alignment — a plain
|
|
588
|
+
* `getStyles` on the unwrapped element trades a typing defect for a styling
|
|
589
|
+
* loss.
|
|
590
|
+
*
|
|
591
|
+
* A declaration of `inherit` states nothing of its own, so it must not shadow
|
|
592
|
+
* the container's value. That is load-bearing rather than pedantic: mjml@5
|
|
593
|
+
* puts every visual property on the wrapper div and writes `color: inherit` on
|
|
594
|
+
* the heading inside it, so honouring the keyword literally drops the colour
|
|
595
|
+
* the email actually renders with.
|
|
596
|
+
*/
|
|
597
|
+
function inheritedStyles(chain) {
|
|
598
|
+
const merged = { ...getStyles$1(chain[0]) };
|
|
599
|
+
for (const $node of chain.slice(1)) for (const [property, value] of Object.entries(getStyles$1($node))) {
|
|
600
|
+
if (value.trim().toLowerCase() === "inherit") continue;
|
|
601
|
+
merged[property] = value;
|
|
602
|
+
}
|
|
603
|
+
return merged;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* The element that takes a container's place when the container's entire
|
|
607
|
+
* meaningful content is one block-level element, together with the styles that
|
|
608
|
+
* element renders with. Returns the element handed in when there is nothing to
|
|
609
|
+
* unwrap.
|
|
610
|
+
*
|
|
611
|
+
* Generator-produced email wraps each text block in a plain `<div>` holding a
|
|
612
|
+
* single block-level element — compiled MJML puts one `<h3>` inside an
|
|
613
|
+
* `mj-text` body that rendered a heading. That `<div>` holds no table, so it
|
|
614
|
+
* is correctly not a container to descend, and `div` is a text tag here:
|
|
615
|
+
* mapping it emits a paragraph with the heading buried in its content, which
|
|
616
|
+
* loses the heading's semantics, its own styling and any downstream treatment
|
|
617
|
+
* of titles.
|
|
618
|
+
*
|
|
619
|
+
* Three constraints, each a hazard a relaxed version would reintroduce:
|
|
620
|
+
*
|
|
621
|
+
* - The chain must *end* on an `UNWRAPPABLE_BLOCK_TAGS` element. That is what
|
|
622
|
+
* keeps a `div.mj-column-per-*` out: its sole child is a `<table>`, and
|
|
623
|
+
* handing that to the dispatch below would html-fallback the whole column.
|
|
624
|
+
* It also leaves a chain of containers bottoming out in bare text alone, so
|
|
625
|
+
* `<div><div>copy</div></div>` keeps mapping as it did.
|
|
626
|
+
* - A container holding a table is refused outright, through the same
|
|
627
|
+
* `isTableContainer` predicate the two traversals use. This decides the one
|
|
628
|
+
* case the tag test cannot — a sole child that *is* a heading, with the
|
|
629
|
+
* table below it — and both traversals descend such a container, so the
|
|
630
|
+
* subtree is theirs rather than this dispatch's.
|
|
631
|
+
* - An empty wrapper is refused, so a container whose sole child renders
|
|
632
|
+
* nothing stays skipped rather than becoming an empty title.
|
|
633
|
+
*
|
|
634
|
+
* Bounded by DOM depth: each step moves to a child.
|
|
635
|
+
*/
|
|
636
|
+
function resolveWrappedBlock($el, $) {
|
|
637
|
+
const unwrapped = {
|
|
638
|
+
$el,
|
|
639
|
+
styles: getStyles$1($el)
|
|
640
|
+
};
|
|
641
|
+
const chain = [$el];
|
|
642
|
+
let $current = $el;
|
|
643
|
+
for (;;) {
|
|
644
|
+
const tag = tagOf($current[0]);
|
|
645
|
+
if (!CONTAINER_TAGS.has(tag)) break;
|
|
646
|
+
if (isTableContainer($current, tag)) break;
|
|
647
|
+
const child = soleElementChild($current);
|
|
648
|
+
if (!child) break;
|
|
649
|
+
const childTag = tagOf(child);
|
|
650
|
+
if (!CONTAINER_TAGS.has(childTag) && !UNWRAPPABLE_BLOCK_TAGS.has(childTag)) break;
|
|
651
|
+
$current = $(child);
|
|
652
|
+
chain.push($current);
|
|
653
|
+
}
|
|
654
|
+
if (chain.length === 1) return unwrapped;
|
|
655
|
+
const $target = chain[chain.length - 1];
|
|
656
|
+
if (!UNWRAPPABLE_BLOCK_TAGS.has(tagOf($target[0]))) return unwrapped;
|
|
657
|
+
if (!hasRenderedContent($target)) return unwrapped;
|
|
658
|
+
return {
|
|
659
|
+
$el: $target,
|
|
660
|
+
styles: inheritedStyles(chain)
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
404
664
|
* Returns the inner HTML of `$el`.
|
|
405
665
|
*/
|
|
406
666
|
function getInnerHtml($el) {
|
|
@@ -416,11 +676,12 @@ function safeHtmlComment(message, raw) {
|
|
|
416
676
|
}
|
|
417
677
|
/**
|
|
418
678
|
* Heading element (h1-h6) → Title block.
|
|
679
|
+
*
|
|
680
|
+
* `styles` is the element's own by default and the wrapper chain's when the
|
|
681
|
+
* heading was unwrapped out of a container — see `resolveWrappedBlock`.
|
|
419
682
|
*/
|
|
420
|
-
function convertHeading($el) {
|
|
421
|
-
const
|
|
422
|
-
const styles = getStyles$1($el);
|
|
423
|
-
const levelMatch = tag.match(/^h(\d)$/);
|
|
683
|
+
function convertHeading($el, styles = getStyles$1($el)) {
|
|
684
|
+
const levelMatch = tagOf($el[0]).match(/^h(\d)$/);
|
|
424
685
|
const rawLevel = levelMatch ? Number(levelMatch[1]) : 2;
|
|
425
686
|
const level = rawLevel >= 1 && rawLevel <= 4 ? rawLevel : Math.min(rawLevel, 4);
|
|
426
687
|
const innerHtml = getInnerHtml($el);
|
|
@@ -453,11 +714,11 @@ function applyTextAlignToParagraphs(html, textAlign) {
|
|
|
453
714
|
});
|
|
454
715
|
}
|
|
455
716
|
/**
|
|
456
|
-
* Paragraph
|
|
717
|
+
* Builds a Paragraph block from a fragment of inline markup, styled by the
|
|
718
|
+
* element that supplied `styles`.
|
|
457
719
|
*/
|
|
458
|
-
function
|
|
459
|
-
const
|
|
460
|
-
const wrapped = ensureParagraphWrapped(getInnerHtml($el));
|
|
720
|
+
function buildParagraph(innerHtml, styles) {
|
|
721
|
+
const wrapped = ensureParagraphWrapped(innerHtml);
|
|
461
722
|
const fontParts = [];
|
|
462
723
|
const fontSize = parsePxValue(styles["font-size"]);
|
|
463
724
|
if (fontSize && fontSize !== 16) fontParts.push(`font-size: ${fontSize}px`);
|
|
@@ -480,9 +741,47 @@ function convertParagraph($el) {
|
|
|
480
741
|
});
|
|
481
742
|
}
|
|
482
743
|
/**
|
|
744
|
+
* Paragraph or block-level text container → Paragraph block.
|
|
745
|
+
*/
|
|
746
|
+
function convertParagraph($el) {
|
|
747
|
+
return buildParagraph(getInnerHtml($el), getStyles$1($el));
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Decides whether a child node of a table cell belongs to a run of inline
|
|
751
|
+
* text rather than to a block of its own: a bare text node, or one of the
|
|
752
|
+
* inline formatting tags.
|
|
753
|
+
*/
|
|
754
|
+
function isInlineContent(node) {
|
|
755
|
+
if (isText(node)) return true;
|
|
756
|
+
return isTag(node) && INLINE_FORMATTING_TAGS.has(node.tagName.toLowerCase());
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Converts a run of consecutive inline nodes lifted out of a table cell into
|
|
760
|
+
* one Paragraph block, keeping their markup inside the paragraph's content.
|
|
761
|
+
*
|
|
762
|
+
* `$cell` supplies the styling: a bare run has no element of its own to read
|
|
763
|
+
* a colour, size or alignment from, and table-based email puts all three on
|
|
764
|
+
* the cell.
|
|
765
|
+
*
|
|
766
|
+
* Returns `null` for a run carrying no text — a cell holding nothing but
|
|
767
|
+
* ` ` and `<br>` has no content, the same reading `convertElement`
|
|
768
|
+
* gives an empty `<p>`.
|
|
769
|
+
*/
|
|
770
|
+
function convertInlineRun(nodes, $cell, $) {
|
|
771
|
+
if (!nodes.map((node) => $(node).text()).join("").trim()) return null;
|
|
772
|
+
return {
|
|
773
|
+
block: buildParagraph(nodes.map((node) => $.html(node)).join(""), getStyles$1($cell)),
|
|
774
|
+
entry: {
|
|
775
|
+
sourceTag: tagOf($cell[0]),
|
|
776
|
+
templaticalBlockType: "paragraph",
|
|
777
|
+
status: "converted"
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
483
782
|
* <img> → Image block.
|
|
484
783
|
*/
|
|
485
|
-
function convertImage($el) {
|
|
784
|
+
function convertImage($el, link) {
|
|
486
785
|
const styles = getStyles$1($el);
|
|
487
786
|
const src = $el.attr("src") ?? "";
|
|
488
787
|
const alt = $el.attr("alt") ?? "";
|
|
@@ -491,6 +790,7 @@ function convertImage($el) {
|
|
|
491
790
|
const readWidth = parsePxValue(widthAttr) || parsePxValue(widthStyle) || void 0;
|
|
492
791
|
const width = readWidth ?? 600;
|
|
493
792
|
const height = parsePxValue($el.attr("height")) || parsePxValue(styles.height) || void 0;
|
|
793
|
+
const href = (link?.url ?? "").trim();
|
|
494
794
|
return createImageBlock({
|
|
495
795
|
src,
|
|
496
796
|
alt,
|
|
@@ -498,8 +798,58 @@ function convertImage($el) {
|
|
|
498
798
|
height,
|
|
499
799
|
borderRadius: parseImageBorderRadius(styles["border-radius"], readWidth, height),
|
|
500
800
|
align: parseAlignment(styles["text-align"], "center"),
|
|
501
|
-
styles: { padding: readPaddingFromStyles(styles) }
|
|
801
|
+
styles: { padding: readPaddingFromStyles(styles) },
|
|
802
|
+
...href ? { linkUrl: href } : {},
|
|
803
|
+
...href && link?.openInNewTab ? { linkOpenInNewTab: true } : {}
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Whether an `<a>` wraps an image. An anchor wrapping only an image carries
|
|
808
|
+
* no text, so a button built from it is labelled by the factory default and
|
|
809
|
+
* discards the image entirely. Asked before both button paths — `convertElement`
|
|
810
|
+
* and `isButtonCell` — and before a mixed image-plus-text anchor can fold
|
|
811
|
+
* into a prose run.
|
|
812
|
+
*/
|
|
813
|
+
function isImageAnchor($el) {
|
|
814
|
+
return $el.find("img").length > 0;
|
|
815
|
+
}
|
|
816
|
+
function isImageOnlyAnchor($el) {
|
|
817
|
+
return isImageAnchor($el) && ($el.text() ?? "").trim() === "";
|
|
818
|
+
}
|
|
819
|
+
function convertLinkedImage($anchor, $img) {
|
|
820
|
+
return {
|
|
821
|
+
block: convertImage($img, {
|
|
822
|
+
url: $anchor.attr("href") ?? "",
|
|
823
|
+
openInNewTab: $anchor.attr("target") === "_blank"
|
|
824
|
+
}),
|
|
825
|
+
entry: {
|
|
826
|
+
sourceTag: "a",
|
|
827
|
+
templaticalBlockType: "image",
|
|
828
|
+
status: "converted"
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* An `<a>` that wraps both an image and text cannot become one block without
|
|
834
|
+
* dropping one of them: `convertElement` returns a single block. The walker
|
|
835
|
+
* emits the image with `linkUrl` and a sibling paragraph that keeps the
|
|
836
|
+
* remaining `<a>`, so neither is lost.
|
|
837
|
+
*/
|
|
838
|
+
function splitMixedImageAnchor($anchor, $host, $) {
|
|
839
|
+
const results = [];
|
|
840
|
+
for (const img of $anchor.find("img").toArray()) results.push(convertLinkedImage($anchor, $(img)));
|
|
841
|
+
const $clone = $anchor.clone();
|
|
842
|
+
$clone.find("img").remove();
|
|
843
|
+
if (($clone.text() ?? "").trim() === "") return results;
|
|
844
|
+
results.push({
|
|
845
|
+
block: buildParagraph($.html($clone) ?? "", getStyles$1($host)),
|
|
846
|
+
entry: {
|
|
847
|
+
sourceTag: tagOf($host[0]),
|
|
848
|
+
templaticalBlockType: "paragraph",
|
|
849
|
+
status: "converted"
|
|
850
|
+
}
|
|
502
851
|
});
|
|
852
|
+
return results;
|
|
503
853
|
}
|
|
504
854
|
/**
|
|
505
855
|
* <a> styled as a button → Button block.
|
|
@@ -516,6 +866,90 @@ function looksLikeButton(styles) {
|
|
|
516
866
|
return false;
|
|
517
867
|
}
|
|
518
868
|
/**
|
|
869
|
+
* Decides whether an `<a>` belongs to the run of prose around it rather than
|
|
870
|
+
* to a block of its own: a link the source did not style as a button, whose
|
|
871
|
+
* own text is what the reader sees.
|
|
872
|
+
*
|
|
873
|
+
* A link inside a sentence is prose, so folding it keeps the sentence in one
|
|
874
|
+
* editable block — and keeps the anchor's markup, `href` included, which the
|
|
875
|
+
* per-element path drops (`convertParagraph` reads inner HTML, so the element
|
|
876
|
+
* itself never reaches the block).
|
|
877
|
+
*
|
|
878
|
+
* Three constraints, each a hazard a relaxed version would reintroduce:
|
|
879
|
+
*
|
|
880
|
+
* - `looksLikeButton` is the same predicate `convertElement` and
|
|
881
|
+
* `isButtonCell` use to tell a call to action from a link, so a styled
|
|
882
|
+
* anchor is never absorbed into a sentence and keeps becoming a button.
|
|
883
|
+
* - The anchor must carry text. `convertInlineRun` reads a run with no text
|
|
884
|
+
* as empty and emits nothing, so an anchor whose content is an image has to
|
|
885
|
+
* keep the block it already gets; folding it would delete the image.
|
|
886
|
+
* - The anchor must not wrap an image. An image-plus-text `<a>` folded into
|
|
887
|
+
* the run would keep the image as raw markup; it is a first-class image
|
|
888
|
+
* with `linkUrl` and a sibling paragraph instead.
|
|
889
|
+
*/
|
|
890
|
+
function isProseAnchor($el) {
|
|
891
|
+
if (looksLikeButton(getStyles$1($el))) return false;
|
|
892
|
+
if (isImageAnchor($el)) return false;
|
|
893
|
+
return ($el.text() ?? "").trim() !== "";
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Walks an element's child *nodes*, grouping consecutive inline content into
|
|
897
|
+
* runs and handing every other element to the caller.
|
|
898
|
+
*
|
|
899
|
+
* A bare text node between two elements is content, and a walk over
|
|
900
|
+
* `children()` never visits it — so `Hello<br>World` loses both words while
|
|
901
|
+
* emitting a block holding nothing but `<br>`. Consecutive inline nodes
|
|
902
|
+
* therefore accumulate into one run and become a single paragraph, which is
|
|
903
|
+
* what makes a bare line agree with the same line wrapped in a `<p>`.
|
|
904
|
+
*
|
|
905
|
+
* One walker for all three content traversals — the body walk, the layout
|
|
906
|
+
* container walk and the cell walk — because a bare text node is content
|
|
907
|
+
* wherever it sits and the three have to read it identically. A second copy
|
|
908
|
+
* of this classification is the hazard: fixing it on one surface leaves the
|
|
909
|
+
* others silently dropping copy the source email displays, and nothing fails
|
|
910
|
+
* to say so. What differs between the three is only what a *block-level*
|
|
911
|
+
* element becomes, which is why that half is the caller's.
|
|
912
|
+
*
|
|
913
|
+
* `$host` is what a run reads its styling and source tag from: a bare run has
|
|
914
|
+
* no element of its own, and the nearest enclosing element is the one carrying
|
|
915
|
+
* the colour, size and alignment it renders with.
|
|
916
|
+
*
|
|
917
|
+
* A run never reaches across `$host`'s own children into a descendant's:
|
|
918
|
+
* `onElement` is called with the run already flushed, so a caller that
|
|
919
|
+
* recurses keeps the text before a block-level child ahead of it.
|
|
920
|
+
*/
|
|
921
|
+
function walkContentNodes($host, $, onRun, onElement) {
|
|
922
|
+
let inlineRun = [];
|
|
923
|
+
const flushInlineRun = () => {
|
|
924
|
+
if (inlineRun.length === 0) return;
|
|
925
|
+
const run = inlineRun;
|
|
926
|
+
inlineRun = [];
|
|
927
|
+
const converted = convertInlineRun(run, $host, $);
|
|
928
|
+
if (converted) onRun(converted);
|
|
929
|
+
};
|
|
930
|
+
for (const node of $host.contents().toArray()) {
|
|
931
|
+
if (isInlineContent(node)) {
|
|
932
|
+
inlineRun.push(node);
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (!isTag(node)) continue;
|
|
936
|
+
const $child = $(node);
|
|
937
|
+
const tag = node.tagName.toLowerCase();
|
|
938
|
+
if (tag === "a" && isImageAnchor($child) && ($child.text() ?? "").trim()) {
|
|
939
|
+
flushInlineRun();
|
|
940
|
+
for (const converted of splitMixedImageAnchor($child, $host, $)) onRun(converted);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (tag === "a" && isProseAnchor($child)) {
|
|
944
|
+
inlineRun.push(node);
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
flushInlineRun();
|
|
948
|
+
onElement($child, tag);
|
|
949
|
+
}
|
|
950
|
+
flushInlineRun();
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
519
953
|
* Reads a button's placement from the cell that wraps it. An anchor styled as
|
|
520
954
|
* a button is sized to its own content, so its `text-align` says nothing about
|
|
521
955
|
* where it sits — table-based email puts that on the containing `<td>`, as
|
|
@@ -577,23 +1011,71 @@ function convertHtmlFallback($el, $, note) {
|
|
|
577
1011
|
});
|
|
578
1012
|
}
|
|
579
1013
|
/**
|
|
580
|
-
* Decides whether a `<td>`
|
|
581
|
-
*
|
|
1014
|
+
* Decides whether a `<td>` / `<th>` carries nothing a reader would see: no
|
|
1015
|
+
* text once source whitespace and ` ` are collapsed, and no element that
|
|
1016
|
+
* renders on its own.
|
|
1017
|
+
*
|
|
1018
|
+
* Text alone is not the test. An image or a link carries no text and is
|
|
1019
|
+
* content all the same, so a cell holding one is never blank — reading it as
|
|
1020
|
+
* blank would make a picture-only column disappear.
|
|
1021
|
+
*
|
|
1022
|
+
* Lives here, with the other predicates both traversal modules consult,
|
|
1023
|
+
* because a spacer cell and a row's gutter cells are one fact read for two
|
|
1024
|
+
* purposes: `isSpacerCell` adds a stated height to it, and the section
|
|
1025
|
+
* builder reads a row's blank cells as chrome rather than as columns. A
|
|
1026
|
+
* second copy would let one cell be a spacer in one traversal and a column
|
|
1027
|
+
* in the other.
|
|
1028
|
+
*/
|
|
1029
|
+
function isBlankCell($el) {
|
|
1030
|
+
if (normalizeCellText($el.text() ?? "") !== "") return false;
|
|
1031
|
+
return $el.find("img, a, hr").length === 0;
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Decides whether a `<td>` looks like a vertical spacer: blank, and carrying
|
|
1035
|
+
* an explicit height.
|
|
582
1036
|
*/
|
|
583
1037
|
function isSpacerCell($el) {
|
|
584
|
-
if (($el
|
|
585
|
-
if ($el.find("img, a, hr").length > 0) return false;
|
|
1038
|
+
if (!isBlankCell($el)) return false;
|
|
586
1039
|
const styles = getStyles$1($el);
|
|
587
1040
|
return parsePxValue($el.attr("height")) > 0 || parsePxValue(styles.height) > 0 || parsePxValue(styles["line-height"]) > 0;
|
|
588
1041
|
}
|
|
589
1042
|
/**
|
|
590
|
-
*
|
|
591
|
-
*
|
|
1043
|
+
* Collapses every run of whitespace — ` ` included — to one space and
|
|
1044
|
+
* trims. Source indentation and nested tags introduce whitespace that never
|
|
1045
|
+
* renders, so a text comparison has to normalise both sides.
|
|
1046
|
+
*/
|
|
1047
|
+
function normalizeCellText(value) {
|
|
1048
|
+
return value.replace(/[\s\u00a0]+/g, " ").trim();
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Whether the anchor *is* the cell rather than sitting inside its content.
|
|
1052
|
+
*
|
|
1053
|
+
* The hazard this guards: `buildCellButton` labels the button with the
|
|
1054
|
+
* anchor's text and drops every other node in the cell, so classifying a
|
|
1055
|
+
* sentence that merely contains a link as a button deletes the sentence. The
|
|
1056
|
+
* constraint is that a cell only reads as a button when the link is its
|
|
1057
|
+
* entire content — and `find("a")` matches at any depth, so an outer callout
|
|
1058
|
+
* cell wrapping a real CTA reaches the same test.
|
|
1059
|
+
*/
|
|
1060
|
+
function isWholeCellAnchor($el, $anchor) {
|
|
1061
|
+
return normalizeCellText($el.text() ?? "") === normalizeCellText($anchor.text() ?? "");
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Decides whether a `<td>` is a button container — i.e. its entire content is
|
|
1065
|
+
* one `<a>`, styled as a button either on the anchor or on the cell.
|
|
1066
|
+
*
|
|
1067
|
+
* Both arms require the anchor to be the cell's whole content. The anchor's
|
|
1068
|
+
* own styling is the stronger signal that a link is *a button*, but it says
|
|
1069
|
+
* nothing about whether the link is *the cell*, and `find("a")` matches at
|
|
1070
|
+
* any depth — so a callout cell holding a paragraph plus a self-styled CTA
|
|
1071
|
+
* satisfies the anchor arm exactly as it does the cell arm.
|
|
592
1072
|
*/
|
|
593
1073
|
function isButtonCell($el, $) {
|
|
594
1074
|
const anchors = $el.find("a");
|
|
595
1075
|
if (anchors.length !== 1) return { match: false };
|
|
596
1076
|
const anchor = $(anchors[0]);
|
|
1077
|
+
if (isImageAnchor(anchor)) return { match: false };
|
|
1078
|
+
if (!isWholeCellAnchor($el, anchor)) return { match: false };
|
|
597
1079
|
if (looksLikeButton(getStyles$1(anchor))) return {
|
|
598
1080
|
match: true,
|
|
599
1081
|
anchor
|
|
@@ -610,14 +1092,22 @@ function isButtonCell($el, $) {
|
|
|
610
1092
|
* Converts a single content-bearing element (heading / paragraph / image /
|
|
611
1093
|
* anchor-as-button / divider) to a Templatical block.
|
|
612
1094
|
*
|
|
1095
|
+
* A container whose entire meaningful content is one block-level element is
|
|
1096
|
+
* unwrapped first, so the element inside is what gets mapped and named in the
|
|
1097
|
+
* report — see `resolveWrappedBlock`. Only a heading can come back from that,
|
|
1098
|
+
* which is why the heading branch is the only one taking the resolved styles:
|
|
1099
|
+
* for every other branch the resolved element is the one handed in, so its own
|
|
1100
|
+
* styles are what `styles` already holds.
|
|
1101
|
+
*
|
|
613
1102
|
* Returns `null` for elements that do not contain any meaningful content
|
|
614
1103
|
* (the caller should skip them).
|
|
615
1104
|
*/
|
|
616
1105
|
function convertElement($el, $) {
|
|
617
|
-
const
|
|
1106
|
+
const { $el: $target, styles } = resolveWrappedBlock($el, $);
|
|
1107
|
+
const tag = tagOf($target[0]);
|
|
618
1108
|
if (!tag) return null;
|
|
619
1109
|
if (HEADING_TAGS.has(tag)) return {
|
|
620
|
-
block: convertHeading($
|
|
1110
|
+
block: convertHeading($target, styles),
|
|
621
1111
|
entry: {
|
|
622
1112
|
sourceTag: tag,
|
|
623
1113
|
templaticalBlockType: "title",
|
|
@@ -625,7 +1115,7 @@ function convertElement($el, $) {
|
|
|
625
1115
|
}
|
|
626
1116
|
};
|
|
627
1117
|
if (tag === "img") return {
|
|
628
|
-
block: convertImage($
|
|
1118
|
+
block: convertImage($target),
|
|
629
1119
|
entry: {
|
|
630
1120
|
sourceTag: tag,
|
|
631
1121
|
templaticalBlockType: "image",
|
|
@@ -633,8 +1123,9 @@ function convertElement($el, $) {
|
|
|
633
1123
|
}
|
|
634
1124
|
};
|
|
635
1125
|
if (tag === "a") {
|
|
636
|
-
if (
|
|
637
|
-
|
|
1126
|
+
if (isImageOnlyAnchor($target)) return convertLinkedImage($target, $($target.find("img")[0]));
|
|
1127
|
+
if (looksLikeButton(styles)) return {
|
|
1128
|
+
block: convertButton($target),
|
|
638
1129
|
entry: {
|
|
639
1130
|
sourceTag: tag,
|
|
640
1131
|
templaticalBlockType: "button",
|
|
@@ -642,7 +1133,7 @@ function convertElement($el, $) {
|
|
|
642
1133
|
}
|
|
643
1134
|
};
|
|
644
1135
|
return {
|
|
645
|
-
block: convertParagraph($
|
|
1136
|
+
block: convertParagraph($target),
|
|
646
1137
|
entry: {
|
|
647
1138
|
sourceTag: tag,
|
|
648
1139
|
templaticalBlockType: "paragraph",
|
|
@@ -652,7 +1143,7 @@ function convertElement($el, $) {
|
|
|
652
1143
|
};
|
|
653
1144
|
}
|
|
654
1145
|
if (tag === "hr") return {
|
|
655
|
-
block: convertDivider($
|
|
1146
|
+
block: convertDivider($target),
|
|
656
1147
|
entry: {
|
|
657
1148
|
sourceTag: tag,
|
|
658
1149
|
templaticalBlockType: "divider",
|
|
@@ -660,9 +1151,9 @@ function convertElement($el, $) {
|
|
|
660
1151
|
}
|
|
661
1152
|
};
|
|
662
1153
|
if (TEXT_TAGS.has(tag)) {
|
|
663
|
-
if (!($
|
|
1154
|
+
if (!hasRenderedContent($target)) return null;
|
|
664
1155
|
return {
|
|
665
|
-
block: convertParagraph($
|
|
1156
|
+
block: convertParagraph($target),
|
|
666
1157
|
entry: {
|
|
667
1158
|
sourceTag: tag,
|
|
668
1159
|
templaticalBlockType: "paragraph",
|
|
@@ -671,7 +1162,7 @@ function convertElement($el, $) {
|
|
|
671
1162
|
};
|
|
672
1163
|
}
|
|
673
1164
|
return {
|
|
674
|
-
block: convertHtmlFallback($
|
|
1165
|
+
block: convertHtmlFallback($target, $, `Unsupported element <${tag}>: preserved as raw HTML`),
|
|
675
1166
|
entry: {
|
|
676
1167
|
sourceTag: tag,
|
|
677
1168
|
templaticalBlockType: "html",
|
|
@@ -681,6 +1172,211 @@ function convertElement($el, $) {
|
|
|
681
1172
|
};
|
|
682
1173
|
}
|
|
683
1174
|
//#endregion
|
|
1175
|
+
//#region src/column-ratio.ts
|
|
1176
|
+
/**
|
|
1177
|
+
* The share of a section's width each column of a layout occupies.
|
|
1178
|
+
*
|
|
1179
|
+
* These are the percentages `@templatical/renderer` emits for the same layout
|
|
1180
|
+
* (`getWidthPercentages`), and they have to stay equal to them: this table is
|
|
1181
|
+
* what a declared ratio is snapped *to*, so a divergence would have the
|
|
1182
|
+
* importer choose `2-1` for a row the renderer then renders at some other
|
|
1183
|
+
* split — a silent disagreement with no failing surface between the two
|
|
1184
|
+
* packages. `@templatical/renderer` is a devDependency of this package and
|
|
1185
|
+
* must stay one (the importer has no runtime need of it), so the values are
|
|
1186
|
+
* copied rather than imported, and the agreement is asserted by the
|
|
1187
|
+
* renderer-agreement test in `column-ratio.test.ts`.
|
|
1188
|
+
*
|
|
1189
|
+
* Keyed by every `ColumnLayout`, so a new layout has to appear here before it
|
|
1190
|
+
* can be snapped to — and the candidate list below is derived from this table
|
|
1191
|
+
* rather than from a hand-written map of count to layouts, so adding one is a
|
|
1192
|
+
* single edit.
|
|
1193
|
+
*
|
|
1194
|
+
* Exported for that agreement test alone, which compares it against
|
|
1195
|
+
* `getWidthPercentages` value by value. Going through `resolveColumnRatio`
|
|
1196
|
+
* instead cannot do the job: a drift smaller than the snap tolerance still
|
|
1197
|
+
* snaps to the same layout, so a 50/50 target quietly moved to 55/45 passes
|
|
1198
|
+
* every behavioural case.
|
|
1199
|
+
*/
|
|
1200
|
+
const LAYOUT_SHARES = {
|
|
1201
|
+
"1": [100],
|
|
1202
|
+
"2": [50, 50],
|
|
1203
|
+
"3": [
|
|
1204
|
+
33.33,
|
|
1205
|
+
33.33,
|
|
1206
|
+
33.34
|
|
1207
|
+
],
|
|
1208
|
+
"1-2": [33.33, 66.67],
|
|
1209
|
+
"2-1": [66.67, 33.33]
|
|
1210
|
+
};
|
|
1211
|
+
/**
|
|
1212
|
+
* How far, in percentage points on the worst column, a declared ratio may sit
|
|
1213
|
+
* from a layout and still be read as that layout.
|
|
1214
|
+
*
|
|
1215
|
+
* Measured against the corpus rather than chosen for roundness. The widest
|
|
1216
|
+
* real match is mailchimp's `width="350"` / `width="190"` sidebar row at
|
|
1217
|
+
* 64.8 / 35.2, **1.9pp** from `2-1`; the nearest declared ratio that must
|
|
1218
|
+
* *not* match is an 80 / 20 split at **13.3pp** from the same layout. Six sits
|
|
1219
|
+
* 3.2x above the first and 2.2x below the second.
|
|
1220
|
+
*
|
|
1221
|
+
* It also has to stay under 8.33, which is half the 16.67pp gap between the
|
|
1222
|
+
* two closest two-column layouts (`2` at 50/50 and `2-1` at 66.67/33.33):
|
|
1223
|
+
* above that the snap windows overlap and one ratio matches two layouts.
|
|
1224
|
+
*/
|
|
1225
|
+
const SNAP_TOLERANCE_PP = 6;
|
|
1226
|
+
/**
|
|
1227
|
+
* `mj-column-per-66-67` → 66.67%, `mj-column-per-50` → 50%. Compiled MJML
|
|
1228
|
+
* carries a column's share in its class name and nowhere else — every column
|
|
1229
|
+
* div also gets `style="width:100%"` — so this signal has to outrank the
|
|
1230
|
+
* inline style or every MJML layout reads as an equal split.
|
|
1231
|
+
*/
|
|
1232
|
+
const MJ_COLUMN_PERCENT = /(?:^|\s)mj-column-per-(\d+)(?:-(\d+))?(?=\s|$)/;
|
|
1233
|
+
/** `mj-column-px-350` → 350px, MJML's fixed-width column variant. */
|
|
1234
|
+
const MJ_COLUMN_PIXELS = /(?:^|\s)mj-column-px-(\d+)(?=\s|$)/;
|
|
1235
|
+
function parseColumnClass(className) {
|
|
1236
|
+
if (!className) return null;
|
|
1237
|
+
const percent = className.match(MJ_COLUMN_PERCENT);
|
|
1238
|
+
if (percent) {
|
|
1239
|
+
const fraction = percent[2] ? `.${percent[2]}` : "";
|
|
1240
|
+
return usableWidth({
|
|
1241
|
+
unit: "%",
|
|
1242
|
+
value: parseFloat(`${percent[1]}${fraction}`)
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
const pixels = className.match(MJ_COLUMN_PIXELS);
|
|
1246
|
+
if (pixels) return usableWidth({
|
|
1247
|
+
unit: "px",
|
|
1248
|
+
value: parseFloat(pixels[1])
|
|
1249
|
+
});
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* A length that states a share of its row, or `null`.
|
|
1254
|
+
*
|
|
1255
|
+
* A bare number is px, which is what a legacy `width="350"` attribute means.
|
|
1256
|
+
* Anything else — `auto`, a calc, an em length — states no share.
|
|
1257
|
+
*/
|
|
1258
|
+
function parseWidth(raw) {
|
|
1259
|
+
if (raw === void 0) return null;
|
|
1260
|
+
const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(px|%)?$/);
|
|
1261
|
+
if (!match) return null;
|
|
1262
|
+
return usableWidth({
|
|
1263
|
+
unit: match[2] === "%" ? "%" : "px",
|
|
1264
|
+
value: parseFloat(match[1])
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Drops a width that cannot express a share of its row.
|
|
1269
|
+
*
|
|
1270
|
+
* `100%` is the trap this exists for: every compiled-MJML column div and every
|
|
1271
|
+
* Cerberus stack column carries `width:100%` beside its real cap, and a cell
|
|
1272
|
+
* claiming the whole row while sharing it with another cell is stating "fill
|
|
1273
|
+
* what is left" rather than a ratio. Normalising it reads two such columns as
|
|
1274
|
+
* an equal split, and one such column beside a 200px one as 33/67 — a ratio
|
|
1275
|
+
* the source never declared. A `100px` width is a real one, so the rule is on
|
|
1276
|
+
* the percentage unit alone.
|
|
1277
|
+
*/
|
|
1278
|
+
function usableWidth(width) {
|
|
1279
|
+
if (!(width.value > 0)) return null;
|
|
1280
|
+
if (width.unit === "%" && width.value >= 100) return null;
|
|
1281
|
+
return width;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* The width a column host declares, read from the strongest signal it carries.
|
|
1285
|
+
*
|
|
1286
|
+
* Priority is by how specifically each signal states a *column's* share: an
|
|
1287
|
+
* `mj-column-*` class names it outright; `max-width` is the cap that decides
|
|
1288
|
+
* an inline-block column's rendered width and so wins over the `width:100%`
|
|
1289
|
+
* sitting beside it; a `width` style and the legacy `width` attribute come
|
|
1290
|
+
* last, style before attribute because CSS beats a presentational attribute in
|
|
1291
|
+
* every browser.
|
|
1292
|
+
*
|
|
1293
|
+
* Takes primitives rather than a Cheerio node so the whole ratio decision is
|
|
1294
|
+
* testable without a DOM.
|
|
1295
|
+
*/
|
|
1296
|
+
function readColumnWidth(className, styles, widthAttr) {
|
|
1297
|
+
return parseColumnClass(className) ?? parseWidth(styles["max-width"]) ?? parseWidth(styles.width) ?? parseWidth(widthAttr);
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* The declared widths as percentages of their row, or `null` when the row
|
|
1301
|
+
* declares no ratio at all.
|
|
1302
|
+
*
|
|
1303
|
+
* Every column must carry a width, in one unit: a single share of an unknown
|
|
1304
|
+
* total states no ratio, and neither does a px width beside a percentage. Both
|
|
1305
|
+
* are refused rather than guessed, because the fallback — the layout the
|
|
1306
|
+
* column count already gives — is correct, and a guess is not.
|
|
1307
|
+
*/
|
|
1308
|
+
function normalizeShares(widths) {
|
|
1309
|
+
if (widths.length === 0) return null;
|
|
1310
|
+
const first = widths[0];
|
|
1311
|
+
if (!first) return null;
|
|
1312
|
+
if (widths.some((width) => !width || width.unit !== first.unit)) return null;
|
|
1313
|
+
const values = widths.map((width) => width.value);
|
|
1314
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
1315
|
+
if (!(total > 0)) return null;
|
|
1316
|
+
return values.map((value) => value / total * 100);
|
|
1317
|
+
}
|
|
1318
|
+
/** The layouts that hold exactly this many columns. */
|
|
1319
|
+
function candidateLayouts(count) {
|
|
1320
|
+
return Object.keys(LAYOUT_SHARES).filter((layout) => LAYOUT_SHARES[layout].length === count);
|
|
1321
|
+
}
|
|
1322
|
+
/** How far the worst column of `shares` sits from `layout`, in points. */
|
|
1323
|
+
function deviation(shares, layout) {
|
|
1324
|
+
const target = LAYOUT_SHARES[layout];
|
|
1325
|
+
return Math.max(...shares.map((share, i) => Math.abs(share - target[i])));
|
|
1326
|
+
}
|
|
1327
|
+
/** The layout `shares` states, or `null` when none is within tolerance. */
|
|
1328
|
+
function snapToLayout(shares) {
|
|
1329
|
+
let best = null;
|
|
1330
|
+
let bestDeviation = Number.POSITIVE_INFINITY;
|
|
1331
|
+
for (const layout of candidateLayouts(shares.length)) {
|
|
1332
|
+
const distance = deviation(shares, layout);
|
|
1333
|
+
if (distance < bestDeviation) {
|
|
1334
|
+
best = layout;
|
|
1335
|
+
bestDeviation = distance;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return best !== null && bestDeviation <= SNAP_TOLERANCE_PP ? best : null;
|
|
1339
|
+
}
|
|
1340
|
+
function formatShare(share) {
|
|
1341
|
+
return `${Math.round(share * 10) / 10}%`;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* What snapping could not express, named as the reader sees it.
|
|
1345
|
+
*
|
|
1346
|
+
* "equal columns" is true because the layouts a count alone produces — `2` and
|
|
1347
|
+
* `3` — are both equal splits; `resolveColumnRatio` is the only caller and
|
|
1348
|
+
* never reaches here for any other. Naming the layout's own percentages
|
|
1349
|
+
* instead would leak the renderer's 33.34 rounding column into a report.
|
|
1350
|
+
*/
|
|
1351
|
+
function describeRatioLoss(shares) {
|
|
1352
|
+
return `Column widths ${shares.map(formatShare).join(" / ")} have no Templatical equivalent. The section was imported as ${shares.length} equal columns.`;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* The layout a row's declared widths choose, given the layout its column count
|
|
1356
|
+
* already produced.
|
|
1357
|
+
*
|
|
1358
|
+
* Widths decide the *ratio* and never the count: `counted` fixes how many
|
|
1359
|
+
* columns there are, and the only layouts considered are the ones holding
|
|
1360
|
+
* exactly that many. A row declaring nothing, declaring only some of its
|
|
1361
|
+
* columns, or mixing units keeps `counted` and reports nothing — there is no
|
|
1362
|
+
* observed ratio to have lost. A ratio outside tolerance keeps `counted` too,
|
|
1363
|
+
* and names itself in a note, which is the only outcome that is a downgrade.
|
|
1364
|
+
*
|
|
1365
|
+
* Call it only for a `counted` that has columns to choose between. A merged
|
|
1366
|
+
* row (`"1"`) has none, and describing the ratio of columns the section no
|
|
1367
|
+
* longer has would contradict the merge note that row already carries.
|
|
1368
|
+
*/
|
|
1369
|
+
function resolveColumnRatio(widths, counted) {
|
|
1370
|
+
const shares = normalizeShares(widths);
|
|
1371
|
+
if (!shares) return { layout: counted };
|
|
1372
|
+
const snapped = snapToLayout(shares);
|
|
1373
|
+
if (snapped) return { layout: snapped };
|
|
1374
|
+
return {
|
|
1375
|
+
layout: counted,
|
|
1376
|
+
note: describeRatioLoss(shares)
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
//#endregion
|
|
684
1380
|
//#region src/section-builder.ts
|
|
685
1381
|
function emptyPadding$1() {
|
|
686
1382
|
return {
|
|
@@ -763,6 +1459,264 @@ function resolveColumnLayout(cellCount, warnings) {
|
|
|
763
1459
|
warnings.push(`Row with ${cellCount} columns was flattened to a single column. Templatical supports up to 3 columns per section.`);
|
|
764
1460
|
return "1";
|
|
765
1461
|
}
|
|
1462
|
+
/**
|
|
1463
|
+
* The one cell a row's content sits in, when every other cell of that row is
|
|
1464
|
+
* chrome rather than a column — or `null` when the row is a layout row in its
|
|
1465
|
+
* own right.
|
|
1466
|
+
*
|
|
1467
|
+
* Table-based email centres a fixed-width body by flanking it with blank
|
|
1468
|
+
* cells, and Foundation-derived markup pads a row out with a blank `expander`
|
|
1469
|
+
* cell. Counting cells reads both as columns: leemunroe's template, the
|
|
1470
|
+
* most-copied table email there is, imported as a three-column section with
|
|
1471
|
+
* the entire email crushed into the middle third and two empty columns beside
|
|
1472
|
+
* it. That is worse than the single column a cell count could never have
|
|
1473
|
+
* produced, so it is the one place a row's cell count is not the column count.
|
|
1474
|
+
*
|
|
1475
|
+
* The signal is content, never width: a cell is chrome when it holds nothing
|
|
1476
|
+
* a reader sees (`isBlankCell`), which covers a ` ` gutter and an empty
|
|
1477
|
+
* `expander` alike, and covers a blank cell stating a height — a horizontal
|
|
1478
|
+
* gutter's height says nothing about the row.
|
|
1479
|
+
*
|
|
1480
|
+
* Two constraints, both hazards a relaxed version would reintroduce:
|
|
1481
|
+
*
|
|
1482
|
+
* - Exactly one cell may carry content. Two filled cells and a blank third
|
|
1483
|
+
* is a grid with an empty slot, and collapsing it would re-flow the filled
|
|
1484
|
+
* columns from thirds to halves.
|
|
1485
|
+
* - A row with content in no cell at all is left alone. Its cells sit side by
|
|
1486
|
+
* side, so it states one gap per column rather than a stack of them, and
|
|
1487
|
+
* merging them would add their heights and invent vertical space.
|
|
1488
|
+
*
|
|
1489
|
+
* This rule is coupled to the container descent in `packagingTablesOf`, and
|
|
1490
|
+
* neither is complete without the other: the descent reaches Foundation's
|
|
1491
|
+
* layout rows, whose blank `expander` cell then reads as a second column
|
|
1492
|
+
* holding nothing but a spacer. Removing this rule turns every one of those
|
|
1493
|
+
* rows into a phantom two-column section.
|
|
1494
|
+
*/
|
|
1495
|
+
function centringCells(cells) {
|
|
1496
|
+
if (cells.length < 2) return null;
|
|
1497
|
+
const withContent = cells.filter(($cell) => !isBlankCell($cell));
|
|
1498
|
+
return withContent.length === 1 ? withContent : null;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Whether the markup below a layout container states columns anywhere: a row
|
|
1502
|
+
* with two or more cells carrying content.
|
|
1503
|
+
*
|
|
1504
|
+
* This gates the container descent below, and only there — the two content
|
|
1505
|
+
* walks descend a container unconditionally, which is right for them because
|
|
1506
|
+
* they convert its children in place. The packaging descent *promotes* the
|
|
1507
|
+
* rows it reaches to sections of their own, so it needs evidence that those
|
|
1508
|
+
* rows are layout rather than stacked content.
|
|
1509
|
+
*
|
|
1510
|
+
* Without the evidence test the descent shatters a section into one section
|
|
1511
|
+
* per block, wherever a single cell holds one container per column instead of
|
|
1512
|
+
* one cell per column. Measured on compiled MJML — a `div.mj-column-per-*`
|
|
1513
|
+
* per column inside one `<td>` — a five-section email imported as thirteen
|
|
1514
|
+
* one-block sections, and Cerberus's hybrid template went from 14 sections to
|
|
1515
|
+
* 24. Neither loses text; both lose the grouping the source stated, and a
|
|
1516
|
+
* cell holding parallel containers has no column count below it to recover in
|
|
1517
|
+
* exchange.
|
|
1518
|
+
*
|
|
1519
|
+
* Content-bearing cells, not cells: a blank-flanked row is one column, which
|
|
1520
|
+
* is what `centringCells` reads it as. Counting bare cells here would make
|
|
1521
|
+
* this the second answer in the file to "is this row a set of columns?".
|
|
1522
|
+
*/
|
|
1523
|
+
function declaresColumnsBelow($el, $) {
|
|
1524
|
+
let found = false;
|
|
1525
|
+
$el.find("tr").each((_, row) => {
|
|
1526
|
+
if (found) return;
|
|
1527
|
+
if (getDirectCells($(row), $).filter(($cell) => !isBlankCell($cell)).length > 1) found = true;
|
|
1528
|
+
});
|
|
1529
|
+
return found;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* The tables that make up a cell's entire meaningful content, or `null` when
|
|
1533
|
+
* the cell holds anything else.
|
|
1534
|
+
*
|
|
1535
|
+
* Anything that is not a table has to leave nothing behind for the cell to
|
|
1536
|
+
* count as packaging: whitespace, comments, and inline formatting carrying no
|
|
1537
|
+
* text all produce no block, so a cell holding tables and a bare `<br>`
|
|
1538
|
+
* qualifies while one holding a heading beside its table does not.
|
|
1539
|
+
*
|
|
1540
|
+
* A layout container is descended rather than refused, through the same
|
|
1541
|
+
* `isTableContainer` predicate the two content walks use — plus the evidence
|
|
1542
|
+
* test above, which is what keeps the descent from shattering a section whose
|
|
1543
|
+
* columns are sibling containers in one cell. Refusing a container outright
|
|
1544
|
+
* made a `<div>` or a `<center>` between the cell and the layout table enough
|
|
1545
|
+
* to defeat the descent, and the cell walk then flattened the whole subtree
|
|
1546
|
+
* into one column: ZURB Inky's output, which wraps every email in a
|
|
1547
|
+
* `<center>`, imported as a single one-column section.
|
|
1548
|
+
*
|
|
1549
|
+
* The recursion is what keeps the guard below intact through the wrapper: a
|
|
1550
|
+
* container holding prose beside its table answers `null`, which propagates,
|
|
1551
|
+
* and the row keeps the section that carries that prose.
|
|
1552
|
+
*
|
|
1553
|
+
* Bounded by DOM depth — a container is descended only when it holds a table,
|
|
1554
|
+
* and each step moves to a child.
|
|
1555
|
+
*/
|
|
1556
|
+
function packagingTablesOf($cell, $) {
|
|
1557
|
+
const tables = [];
|
|
1558
|
+
let inlineText = "";
|
|
1559
|
+
for (const node of $cell.contents().toArray()) {
|
|
1560
|
+
if (isInlineContent(node)) {
|
|
1561
|
+
inlineText += $(node).text();
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
if (!isTag(node)) continue;
|
|
1565
|
+
const tag = node.tagName.toLowerCase();
|
|
1566
|
+
const $child = $(node);
|
|
1567
|
+
if (tag === "table") {
|
|
1568
|
+
tables.push($child);
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
if (isTableContainer($child, tag) && declaresColumnsBelow($child, $)) {
|
|
1572
|
+
const nested = packagingTablesOf($child, $);
|
|
1573
|
+
if (nested === null) return null;
|
|
1574
|
+
tables.push(...nested);
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
if (tables.length === 0) return null;
|
|
1580
|
+
if (inlineText.trim() !== "") return null;
|
|
1581
|
+
return tables;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* The tables a row is merely packaging for, or `null` when the row is layout
|
|
1585
|
+
* in its own right.
|
|
1586
|
+
*
|
|
1587
|
+
* Table-based email buries the row that states the real column count under
|
|
1588
|
+
* one-cell wrapper tables, and a section emitted for a wrapper resolves
|
|
1589
|
+
* `columns` from that single cell — reporting one column for a row that has
|
|
1590
|
+
* two or three. Descending to the table inside reads the count off the row
|
|
1591
|
+
* that actually declares it, which is counting cells rather than inferring a
|
|
1592
|
+
* layout from widths or class names.
|
|
1593
|
+
*
|
|
1594
|
+
* Two conditions keep the descent from losing anything, and both are hazards
|
|
1595
|
+
* a future edit would reintroduce by relaxing them:
|
|
1596
|
+
*
|
|
1597
|
+
* - The cell's meaningful content must *be* the tables. Descending discards
|
|
1598
|
+
* the row, so a heading or an image beside the table would be dropped.
|
|
1599
|
+
* - The row must carry no background and no padding. The section it emits is
|
|
1600
|
+
* the only carrier for those, so descending past a styled row would drop
|
|
1601
|
+
* the band it paints.
|
|
1602
|
+
*/
|
|
1603
|
+
function packagingRowTables($row, cells, $) {
|
|
1604
|
+
if (cells.length !== 1) return null;
|
|
1605
|
+
const rowStyles = getStyles($row);
|
|
1606
|
+
if (parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background)) return null;
|
|
1607
|
+
const padding = readPaddingFromStyles(rowStyles);
|
|
1608
|
+
if (padding.top || padding.right || padding.bottom || padding.left) return null;
|
|
1609
|
+
return packagingTablesOf(cells[0], $);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* The report entry for the section a layout row produces.
|
|
1613
|
+
*
|
|
1614
|
+
* Whether the row was downgraded is read off the slots that were actually
|
|
1615
|
+
* built: one slot per column means every column kept its own slot, while
|
|
1616
|
+
* fewer slots than columns means `resolveColumnLayout` merged them. Deciding
|
|
1617
|
+
* it by comparing the column count against the column ceiling instead would
|
|
1618
|
+
* be a second source of truth for that ceiling and would start lying the
|
|
1619
|
+
* moment the resolver changed. The ceiling appears only in the note's
|
|
1620
|
+
* wording, where it explains the merge to a reader rather than driving the
|
|
1621
|
+
* branch.
|
|
1622
|
+
*
|
|
1623
|
+
* A faithful row gets no `note` at all. Attaching one unconditionally makes
|
|
1624
|
+
* "nothing was lost" indistinguishable from a downgrade for a caller that
|
|
1625
|
+
* filters on `note`, which is the whole reason the field is optional.
|
|
1626
|
+
*
|
|
1627
|
+
* `columnCount` is the row's column hosts — layout cells, or the sibling
|
|
1628
|
+
* column `<div>`s a single cell holds — not every cell the row has: a
|
|
1629
|
+
* centring row's gutters were never columns, so counting them would report a
|
|
1630
|
+
* three-into-one merge for a row that always stated one column.
|
|
1631
|
+
*/
|
|
1632
|
+
function sectionEntry(columnCount, slotCount, ratioNote) {
|
|
1633
|
+
if (slotCount !== columnCount) return {
|
|
1634
|
+
sourceTag: "tr",
|
|
1635
|
+
templaticalBlockType: "section",
|
|
1636
|
+
status: "approximated",
|
|
1637
|
+
note: `Row of ${columnCount} columns was merged into a single column. Templatical sections hold at most 3 columns.`
|
|
1638
|
+
};
|
|
1639
|
+
if (ratioNote) return {
|
|
1640
|
+
sourceTag: "tr",
|
|
1641
|
+
templaticalBlockType: "section",
|
|
1642
|
+
status: "approximated",
|
|
1643
|
+
note: ratioNote
|
|
1644
|
+
};
|
|
1645
|
+
return {
|
|
1646
|
+
sourceTag: "tr",
|
|
1647
|
+
templaticalBlockType: "section",
|
|
1648
|
+
status: "converted"
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* The report entry for a nested row whose section wrapper was dropped, or
|
|
1653
|
+
* `null` when dropping it lost nothing.
|
|
1654
|
+
*
|
|
1655
|
+
* `packages/core/src/editor.ts` forbids a section inside a column because MJML
|
|
1656
|
+
* forbids `mj-section` there, so a layout table reached from a cell has to
|
|
1657
|
+
* flatten — the columns are gone regardless. One cell has no columns to lose,
|
|
1658
|
+
* and reporting that as a downgrade would fill the report with entries for a
|
|
1659
|
+
* non-event.
|
|
1660
|
+
*
|
|
1661
|
+
* The count is the row's column hosts, for the same reason `sectionEntry`'s
|
|
1662
|
+
* is: a centring row flattened into a parent column lost nothing, so it must
|
|
1663
|
+
* report nothing.
|
|
1664
|
+
*/
|
|
1665
|
+
function flattenedRowEntry(columnCount) {
|
|
1666
|
+
if (columnCount <= 1) return null;
|
|
1667
|
+
return {
|
|
1668
|
+
sourceTag: "tr",
|
|
1669
|
+
templaticalBlockType: null,
|
|
1670
|
+
status: "approximated",
|
|
1671
|
+
note: `Nested row of ${columnCount} columns lost its columns. A Templatical section cannot nest inside a column, so its columns were merged into the surrounding column.`
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* The elements that each carry one of the row's columns.
|
|
1676
|
+
*
|
|
1677
|
+
* A cell holding a column set is replaced by that set, so the count comes from
|
|
1678
|
+
* the divs rather than from the one cell around them. Only a single layout
|
|
1679
|
+
* cell is considered: columns inside a column are not representable, so a
|
|
1680
|
+
* multi-cell row keeps its cells and can never have its count *reduced* by
|
|
1681
|
+
* this rule.
|
|
1682
|
+
*
|
|
1683
|
+
* Asked after `centringCells`, which is what lets a gutter-flanked row whose
|
|
1684
|
+
* middle cell holds a column set still be read as that set.
|
|
1685
|
+
*/
|
|
1686
|
+
function columnHostsOf(layoutCells, $) {
|
|
1687
|
+
if (layoutCells.length === 1) {
|
|
1688
|
+
const containers = columnDivsOf(layoutCells[0], $);
|
|
1689
|
+
if (containers) return containers.map(($el) => ({
|
|
1690
|
+
$el,
|
|
1691
|
+
kind: "container"
|
|
1692
|
+
}));
|
|
1693
|
+
}
|
|
1694
|
+
return layoutCells.map(($el) => ({
|
|
1695
|
+
$el,
|
|
1696
|
+
kind: "cell"
|
|
1697
|
+
}));
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* The blocks a column host contributes.
|
|
1701
|
+
*
|
|
1702
|
+
* A promoted container takes the content walk rather than the cell walk,
|
|
1703
|
+
* which is the same walk a container reached from inside a cell already gets
|
|
1704
|
+
* — so promoting one changes which slot its blocks land in and nothing about
|
|
1705
|
+
* how they convert. The two early returns the cell walk adds are about cells
|
|
1706
|
+
* specifically — `isSpacerCell` reads a `<td height>`, and `isButtonCell`
|
|
1707
|
+
* reads the cell-level styling table-based email wraps a call to action in —
|
|
1708
|
+
* and `looksLikeButton` answers true for `display: inline-block`, which every
|
|
1709
|
+
* column container carries. Handing a container to the cell walk would turn a
|
|
1710
|
+
* column whose content is one link into a single button block and drop
|
|
1711
|
+
* everything the column's own table holds.
|
|
1712
|
+
*/
|
|
1713
|
+
function extractHostBlocks(host, $, entries, warnings) {
|
|
1714
|
+
return host.kind === "cell" ? extractCellBlocks(host.$el, $, entries, warnings) : extractContentBlocks(host.$el, $, entries, warnings);
|
|
1715
|
+
}
|
|
1716
|
+
/** The width an element declares, from the strongest signal it carries. */
|
|
1717
|
+
function readDeclaredWidth($el) {
|
|
1718
|
+
return readColumnWidth($el.attr("class"), getStyles($el), $el.attr("width"));
|
|
1719
|
+
}
|
|
766
1720
|
function extractCellBlocks($cell, $, entries, warnings) {
|
|
767
1721
|
if (isSpacerCell($cell)) {
|
|
768
1722
|
entries.push({
|
|
@@ -781,39 +1735,41 @@ function extractCellBlocks($cell, $, entries, warnings) {
|
|
|
781
1735
|
});
|
|
782
1736
|
return [buildCellButton($cell, btn.anchor)];
|
|
783
1737
|
}
|
|
1738
|
+
return extractContentBlocks($cell, $, entries, warnings);
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* The blocks an element's child nodes produce, for an element that holds
|
|
1742
|
+
* content rather than being content itself: a table cell, or a layout
|
|
1743
|
+
* container descended from one.
|
|
1744
|
+
*
|
|
1745
|
+
* Node classification — bare text and inline markup grouped into runs, a
|
|
1746
|
+
* prose anchor folded into the run around it, comments passed over without
|
|
1747
|
+
* splitting one — is `walkContentNodes`' half, shared with the body walk in
|
|
1748
|
+
* `converter.ts`. What is left here is the half that differs: inside a cell a
|
|
1749
|
+
* nested table flattens into the surrounding column, where at body level it
|
|
1750
|
+
* becomes a section of its own.
|
|
1751
|
+
*/
|
|
1752
|
+
function extractContentBlocks($host, $, entries, warnings) {
|
|
784
1753
|
const blocks = [];
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
if (r) {
|
|
790
|
-
entries.push(r.entry);
|
|
791
|
-
blocks.push(r.block);
|
|
792
|
-
}
|
|
793
|
-
return blocks;
|
|
794
|
-
}
|
|
795
|
-
for (const childEl of childEls) {
|
|
796
|
-
const $child = $(childEl);
|
|
797
|
-
const tag = childEl.tagName?.toLowerCase() ?? "";
|
|
1754
|
+
walkContentNodes($host, $, ({ block, entry }) => {
|
|
1755
|
+
entries.push(entry);
|
|
1756
|
+
blocks.push(block);
|
|
1757
|
+
}, ($child, tag) => {
|
|
798
1758
|
if (tag === "table") {
|
|
799
1759
|
const inner = processTable($child, $, entries, warnings, true);
|
|
800
1760
|
blocks.push(...inner);
|
|
801
|
-
|
|
1761
|
+
return;
|
|
802
1762
|
}
|
|
803
|
-
if (
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
entries.push(r.entry);
|
|
807
|
-
blocks.push(r.block);
|
|
808
|
-
}
|
|
809
|
-
continue;
|
|
1763
|
+
if (isTableContainer($child, tag)) {
|
|
1764
|
+
blocks.push(...extractContentBlocks($child, $, entries, warnings));
|
|
1765
|
+
return;
|
|
810
1766
|
}
|
|
811
1767
|
const r = convertElement($child, $);
|
|
812
1768
|
if (r) {
|
|
813
1769
|
entries.push(r.entry);
|
|
814
1770
|
blocks.push(r.block);
|
|
815
1771
|
}
|
|
816
|
-
}
|
|
1772
|
+
});
|
|
817
1773
|
return blocks;
|
|
818
1774
|
}
|
|
819
1775
|
/**
|
|
@@ -839,22 +1795,35 @@ function processTable($table, $, entries, warnings, flattenInline = false) {
|
|
|
839
1795
|
for (const $row of rows) {
|
|
840
1796
|
const cells = getDirectCells($row, $);
|
|
841
1797
|
if (cells.length === 0) continue;
|
|
842
|
-
const
|
|
1798
|
+
const packaging = packagingRowTables($row, cells, $);
|
|
1799
|
+
if (packaging) {
|
|
1800
|
+
for (const $inner of packaging) sections.push(...processTable($inner, $, entries, warnings, flattenInline));
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
const hosts = columnHostsOf(centringCells(cells) ?? cells, $);
|
|
1804
|
+
const countedLayout = resolveColumnLayout(hosts.length, warnings);
|
|
843
1805
|
let columnsBlocks;
|
|
844
|
-
if (
|
|
1806
|
+
if (countedLayout === "1") {
|
|
845
1807
|
const merged = [];
|
|
846
|
-
for (const
|
|
1808
|
+
for (const host of hosts) merged.push(...extractHostBlocks(host, $, entries, warnings));
|
|
847
1809
|
columnsBlocks = [merged];
|
|
848
|
-
} else columnsBlocks =
|
|
1810
|
+
} else columnsBlocks = hosts.map((host) => extractHostBlocks(host, $, entries, warnings));
|
|
849
1811
|
if (flattenInline) {
|
|
1812
|
+
const dropped = flattenedRowEntry(hosts.length);
|
|
1813
|
+
if (dropped) entries.push(dropped);
|
|
850
1814
|
for (const col of columnsBlocks) sections.push(...col);
|
|
851
1815
|
continue;
|
|
852
1816
|
}
|
|
1817
|
+
const ratio = countedLayout === "1" ? {
|
|
1818
|
+
layout: countedLayout,
|
|
1819
|
+
note: void 0
|
|
1820
|
+
} : resolveColumnRatio(hosts.map((host) => readDeclaredWidth(host.$el)), countedLayout);
|
|
853
1821
|
const rowStyles = getStyles($row);
|
|
854
1822
|
const bgColor = parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background);
|
|
855
1823
|
const padding = readPaddingFromStyles(rowStyles);
|
|
1824
|
+
entries.push(sectionEntry(hosts.length, columnsBlocks.length, ratio.note));
|
|
856
1825
|
sections.push(createSectionBlock({
|
|
857
|
-
columns: layout,
|
|
1826
|
+
columns: ratio.layout,
|
|
858
1827
|
children: columnsBlocks,
|
|
859
1828
|
styles: {
|
|
860
1829
|
padding,
|
|
@@ -903,9 +1872,21 @@ function extractSettings($) {
|
|
|
903
1872
|
}
|
|
904
1873
|
/**
|
|
905
1874
|
* Wrap a list of free-floating blocks (those produced by top-level non-table
|
|
906
|
-
* elements) in a single one-column section.
|
|
1875
|
+
* elements) in a single one-column section, and report the section.
|
|
1876
|
+
*
|
|
1877
|
+
* The section corresponds to no source element, so the report names `body` and
|
|
1878
|
+
* says the section is synthetic — otherwise a caller counting sections against
|
|
1879
|
+
* the rows it can see in the source finds one it cannot account for. Nothing is
|
|
1880
|
+
* lost on this path: every loose block keeps its order inside the one column,
|
|
1881
|
+
* which is why the status is `converted` rather than an approximation.
|
|
907
1882
|
*/
|
|
908
|
-
function wrapInSection(blocks) {
|
|
1883
|
+
function wrapInSection(blocks, entries) {
|
|
1884
|
+
entries.push({
|
|
1885
|
+
sourceTag: "body",
|
|
1886
|
+
templaticalBlockType: "section",
|
|
1887
|
+
status: "converted",
|
|
1888
|
+
note: "Loose top-level content was grouped into a synthetic single-column section."
|
|
1889
|
+
});
|
|
909
1890
|
return createSectionBlock({
|
|
910
1891
|
columns: "1",
|
|
911
1892
|
children: [blocks],
|
|
@@ -913,53 +1894,82 @@ function wrapInSection(blocks) {
|
|
|
913
1894
|
});
|
|
914
1895
|
}
|
|
915
1896
|
/**
|
|
916
|
-
* Walk
|
|
917
|
-
*
|
|
1897
|
+
* Walk the body's child nodes. Tables become sections; loose content is
|
|
1898
|
+
* accumulated and wrapped in a single one-column section.
|
|
1899
|
+
*
|
|
1900
|
+
* Both walks below go through `walkContentNodes`, the same node
|
|
1901
|
+
* classification the cell walk uses, so bare text and inline markup at body
|
|
1902
|
+
* level and inside a layout container reach a rich-text block. A walk over
|
|
1903
|
+
* `children()` visits neither, which drops copy the source email displays —
|
|
1904
|
+
* `Lead<h2>H</h2>Trailing` imported as the heading alone.
|
|
918
1905
|
*/
|
|
919
1906
|
function processBody($, entries, warnings) {
|
|
920
1907
|
const blocks = [];
|
|
921
|
-
const
|
|
1908
|
+
const $body = $("body");
|
|
922
1909
|
let pendingLoose = [];
|
|
923
1910
|
const flushLoose = () => {
|
|
924
1911
|
if (pendingLoose.length > 0) {
|
|
925
|
-
blocks.push(wrapInSection(pendingLoose));
|
|
1912
|
+
blocks.push(wrapInSection(pendingLoose, entries));
|
|
926
1913
|
pendingLoose = [];
|
|
927
1914
|
}
|
|
928
1915
|
};
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1916
|
+
const collectLoose = ({ block, entry }) => {
|
|
1917
|
+
entries.push(entry);
|
|
1918
|
+
pendingLoose.push(block);
|
|
1919
|
+
};
|
|
1920
|
+
/**
|
|
1921
|
+
* Descend a layout container, taking its tables as sections and everything
|
|
1922
|
+
* else as loose content. A container holding another container descends
|
|
1923
|
+
* again, so a table reaches `processTable` at whatever depth the wrapper
|
|
1924
|
+
* markup buries it — MJML nests an outer body div around one div per
|
|
1925
|
+
* section around the section's table, and a single-level walk sees only the
|
|
1926
|
+
* middle div, which the block mapper turns into one paragraph swallowing
|
|
1927
|
+
* the entire table subtree.
|
|
1928
|
+
*
|
|
1929
|
+
* Bounded by DOM depth: a container is descended only when it holds a
|
|
1930
|
+
* table, and each step moves to a child.
|
|
1931
|
+
*
|
|
1932
|
+
* Declared here so every depth shares `pendingLoose` and `flushLoose`. A
|
|
1933
|
+
* per-level accumulator flushes at the wrong point and reorders the
|
|
1934
|
+
* document: content sitting before a nested table lands after it.
|
|
1935
|
+
*/
|
|
1936
|
+
const walkContainer = ($container) => {
|
|
1937
|
+
walkContentNodes($container, $, collectLoose, ($inner, innerTag) => {
|
|
1938
|
+
if (innerTag === "table") {
|
|
1939
|
+
flushLoose();
|
|
1940
|
+
blocks.push(...processTable($inner, $, entries, warnings, false));
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
if (isTableContainer($inner, innerTag)) {
|
|
1944
|
+
walkContainer($inner);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
const r = convertElement($inner, $);
|
|
1948
|
+
if (r) {
|
|
1949
|
+
entries.push(r.entry);
|
|
1950
|
+
pendingLoose.push(r.block);
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
};
|
|
1954
|
+
walkContentNodes($body, $, collectLoose, ($child, tag) => {
|
|
932
1955
|
if (tag === "table") {
|
|
933
1956
|
flushLoose();
|
|
934
1957
|
blocks.push(...processTable($child, $, entries, warnings, false));
|
|
935
|
-
|
|
1958
|
+
return;
|
|
936
1959
|
}
|
|
937
|
-
if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none")
|
|
938
|
-
if ((
|
|
1960
|
+
if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none") return;
|
|
1961
|
+
if (isTableContainer($child, tag)) {
|
|
939
1962
|
flushLoose();
|
|
940
|
-
$child
|
|
941
|
-
const innerTag = innerEl.tagName?.toLowerCase() ?? "";
|
|
942
|
-
const $inner = $(innerEl);
|
|
943
|
-
if (innerTag === "table") {
|
|
944
|
-
flushLoose();
|
|
945
|
-
blocks.push(...processTable($inner, $, entries, warnings, false));
|
|
946
|
-
} else {
|
|
947
|
-
const r = convertElement($inner, $);
|
|
948
|
-
if (r) {
|
|
949
|
-
entries.push(r.entry);
|
|
950
|
-
pendingLoose.push(r.block);
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
});
|
|
1963
|
+
walkContainer($child);
|
|
954
1964
|
flushLoose();
|
|
955
|
-
|
|
1965
|
+
return;
|
|
956
1966
|
}
|
|
957
1967
|
const r = convertElement($child, $);
|
|
958
1968
|
if (r) {
|
|
959
1969
|
entries.push(r.entry);
|
|
960
1970
|
pendingLoose.push(r.block);
|
|
961
1971
|
}
|
|
962
|
-
}
|
|
1972
|
+
});
|
|
963
1973
|
flushLoose();
|
|
964
1974
|
return blocks;
|
|
965
1975
|
}
|