@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 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 `&nbsp;` 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 tag = tagOf($el[0]);
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 or block-level text container → Paragraph block.
717
+ * Builds a Paragraph block from a fragment of inline markup, styled by the
718
+ * element that supplied `styles`.
457
719
  */
458
- function convertParagraph($el) {
459
- const styles = getStyles$1($el);
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
+ * `&nbsp;` 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>` looks like a vertical spacer:
581
- * empty (or only `&nbsp;`) AND has an explicit height.
1014
+ * Decides whether a `<td>` / `<th>` carries nothing a reader would see: no
1015
+ * text once source whitespace and `&nbsp;` 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.text() ?? "").replace(/\s| /g, "") !== "") return false;
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
- * Decides whether a `<td>` is a button container — i.e. has exactly one
591
- * `<a>` inside that itself looks like a button.
1043
+ * Collapses every run of whitespace — `&nbsp;` 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 tag = tagOf($el[0]);
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($el),
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($el),
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 (looksLikeButton(getStyles$1($el))) return {
637
- block: convertButton($el),
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($el),
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($el),
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 (!($el.text() ?? "").trim() && $el.find("img, a").length === 0) return null;
1154
+ if (!hasRenderedContent($target)) return null;
664
1155
  return {
665
- block: convertParagraph($el),
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($el, $, `Unsupported element <${tag}>: preserved as raw HTML`),
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 `&nbsp;` 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
- const childEls = $cell.children().toArray();
786
- if (childEls.length === 0) {
787
- if (!($cell.text() ?? "").trim()) return [];
788
- const r = convertElement($cell, $);
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
- continue;
1761
+ return;
802
1762
  }
803
- if (tag === "a" && looksLikeButton(getStyles($child))) {
804
- const r = convertElement($child, $);
805
- if (r) {
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 layout = resolveColumnLayout(cells.length, warnings);
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 (layout === "1") {
1806
+ if (countedLayout === "1") {
845
1807
  const merged = [];
846
- for (const $cell of cells) merged.push(...extractCellBlocks($cell, $, entries, warnings));
1808
+ for (const host of hosts) merged.push(...extractHostBlocks(host, $, entries, warnings));
847
1809
  columnsBlocks = [merged];
848
- } else columnsBlocks = cells.map(($cell) => extractCellBlocks($cell, $, entries, warnings));
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 top-level body children. Tables become sections; loose content
917
- * elements are accumulated and wrapped in a single one-column section.
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 children = $("body").children().toArray();
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
- for (const childEl of children) {
930
- const tag = childEl.tagName?.toLowerCase() ?? "";
931
- const $child = $(childEl);
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
- continue;
1958
+ return;
936
1959
  }
937
- if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none") continue;
938
- if ((tag === "div" || tag === "center" || tag === "main") && $child.find("table").length > 0) {
1960
+ if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none") return;
1961
+ if (isTableContainer($child, tag)) {
939
1962
  flushLoose();
940
- $child.children().each((_, innerEl) => {
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
- continue;
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
  }