@templatical/import-html 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,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,6 +741,44 @@ 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
784
  function convertImage($el) {
@@ -516,6 +815,81 @@ function looksLikeButton(styles) {
516
815
  return false;
517
816
  }
518
817
  /**
818
+ * Decides whether an `<a>` belongs to the run of prose around it rather than
819
+ * to a block of its own: a link the source did not style as a button, whose
820
+ * own text is what the reader sees.
821
+ *
822
+ * A link inside a sentence is prose, so folding it keeps the sentence in one
823
+ * editable block — and keeps the anchor's markup, `href` included, which the
824
+ * per-element path drops (`convertParagraph` reads inner HTML, so the element
825
+ * itself never reaches the block).
826
+ *
827
+ * Two constraints, both hazards a relaxed version would reintroduce:
828
+ *
829
+ * - `looksLikeButton` is the same predicate `convertElement` and
830
+ * `isButtonCell` use to tell a call to action from a link, so a styled
831
+ * anchor is never absorbed into a sentence and keeps becoming a button.
832
+ * - The anchor must carry text. `convertInlineRun` reads a run with no text
833
+ * as empty and emits nothing, so an anchor whose content is an image has to
834
+ * keep the block it already gets; folding it would delete the image.
835
+ */
836
+ function isProseAnchor($el) {
837
+ if (looksLikeButton(getStyles$1($el))) return false;
838
+ return ($el.text() ?? "").trim() !== "";
839
+ }
840
+ /**
841
+ * Walks an element's child *nodes*, grouping consecutive inline content into
842
+ * runs and handing every other element to the caller.
843
+ *
844
+ * A bare text node between two elements is content, and a walk over
845
+ * `children()` never visits it — so `Hello<br>World` loses both words while
846
+ * emitting a block holding nothing but `<br>`. Consecutive inline nodes
847
+ * therefore accumulate into one run and become a single paragraph, which is
848
+ * what makes a bare line agree with the same line wrapped in a `<p>`.
849
+ *
850
+ * One walker for all three content traversals — the body walk, the layout
851
+ * container walk and the cell walk — because a bare text node is content
852
+ * wherever it sits and the three have to read it identically. A second copy
853
+ * of this classification is the hazard: fixing it on one surface leaves the
854
+ * others silently dropping copy the source email displays, and nothing fails
855
+ * to say so. What differs between the three is only what a *block-level*
856
+ * element becomes, which is why that half is the caller's.
857
+ *
858
+ * `$host` is what a run reads its styling and source tag from: a bare run has
859
+ * no element of its own, and the nearest enclosing element is the one carrying
860
+ * the colour, size and alignment it renders with.
861
+ *
862
+ * A run never reaches across `$host`'s own children into a descendant's:
863
+ * `onElement` is called with the run already flushed, so a caller that
864
+ * recurses keeps the text before a block-level child ahead of it.
865
+ */
866
+ function walkContentNodes($host, $, onRun, onElement) {
867
+ let inlineRun = [];
868
+ const flushInlineRun = () => {
869
+ if (inlineRun.length === 0) return;
870
+ const run = inlineRun;
871
+ inlineRun = [];
872
+ const converted = convertInlineRun(run, $host, $);
873
+ if (converted) onRun(converted);
874
+ };
875
+ for (const node of $host.contents().toArray()) {
876
+ if (isInlineContent(node)) {
877
+ inlineRun.push(node);
878
+ continue;
879
+ }
880
+ if (!isTag(node)) continue;
881
+ const $child = $(node);
882
+ const tag = node.tagName.toLowerCase();
883
+ if (tag === "a" && isProseAnchor($child)) {
884
+ inlineRun.push(node);
885
+ continue;
886
+ }
887
+ flushInlineRun();
888
+ onElement($child, tag);
889
+ }
890
+ flushInlineRun();
891
+ }
892
+ /**
519
893
  * Reads a button's placement from the cell that wraps it. An anchor styled as
520
894
  * a button is sized to its own content, so its `text-align` says nothing about
521
895
  * where it sits — table-based email puts that on the containing `<td>`, as
@@ -577,23 +951,70 @@ function convertHtmlFallback($el, $, note) {
577
951
  });
578
952
  }
579
953
  /**
580
- * Decides whether a `<td>` looks like a vertical spacer:
581
- * empty (or only `&nbsp;`) AND has an explicit height.
954
+ * Decides whether a `<td>` / `<th>` carries nothing a reader would see: no
955
+ * text once source whitespace and `&nbsp;` are collapsed, and no element that
956
+ * renders on its own.
957
+ *
958
+ * Text alone is not the test. An image or a link carries no text and is
959
+ * content all the same, so a cell holding one is never blank — reading it as
960
+ * blank would make a picture-only column disappear.
961
+ *
962
+ * Lives here, with the other predicates both traversal modules consult,
963
+ * because a spacer cell and a row's gutter cells are one fact read for two
964
+ * purposes: `isSpacerCell` adds a stated height to it, and the section
965
+ * builder reads a row's blank cells as chrome rather than as columns. A
966
+ * second copy would let one cell be a spacer in one traversal and a column
967
+ * in the other.
968
+ */
969
+ function isBlankCell($el) {
970
+ if (normalizeCellText($el.text() ?? "") !== "") return false;
971
+ return $el.find("img, a, hr").length === 0;
972
+ }
973
+ /**
974
+ * Decides whether a `<td>` looks like a vertical spacer: blank, and carrying
975
+ * an explicit height.
582
976
  */
583
977
  function isSpacerCell($el) {
584
- if (($el.text() ?? "").replace(/\s| /g, "") !== "") return false;
585
- if ($el.find("img, a, hr").length > 0) return false;
978
+ if (!isBlankCell($el)) return false;
586
979
  const styles = getStyles$1($el);
587
980
  return parsePxValue($el.attr("height")) > 0 || parsePxValue(styles.height) > 0 || parsePxValue(styles["line-height"]) > 0;
588
981
  }
589
982
  /**
590
- * Decides whether a `<td>` is a button container — i.e. has exactly one
591
- * `<a>` inside that itself looks like a button.
983
+ * Collapses every run of whitespace — `&nbsp;` included — to one space and
984
+ * trims. Source indentation and nested tags introduce whitespace that never
985
+ * renders, so a text comparison has to normalise both sides.
986
+ */
987
+ function normalizeCellText(value) {
988
+ return value.replace(/[\s\u00a0]+/g, " ").trim();
989
+ }
990
+ /**
991
+ * Whether the anchor *is* the cell rather than sitting inside its content.
992
+ *
993
+ * The hazard this guards: `buildCellButton` labels the button with the
994
+ * anchor's text and drops every other node in the cell, so classifying a
995
+ * sentence that merely contains a link as a button deletes the sentence. The
996
+ * constraint is that a cell only reads as a button when the link is its
997
+ * entire content — and `find("a")` matches at any depth, so an outer callout
998
+ * cell wrapping a real CTA reaches the same test.
999
+ */
1000
+ function isWholeCellAnchor($el, $anchor) {
1001
+ return normalizeCellText($el.text() ?? "") === normalizeCellText($anchor.text() ?? "");
1002
+ }
1003
+ /**
1004
+ * Decides whether a `<td>` is a button container — i.e. its entire content is
1005
+ * one `<a>`, styled as a button either on the anchor or on the cell.
1006
+ *
1007
+ * Both arms require the anchor to be the cell's whole content. The anchor's
1008
+ * own styling is the stronger signal that a link is *a button*, but it says
1009
+ * nothing about whether the link is *the cell*, and `find("a")` matches at
1010
+ * any depth — so a callout cell holding a paragraph plus a self-styled CTA
1011
+ * satisfies the anchor arm exactly as it does the cell arm.
592
1012
  */
593
1013
  function isButtonCell($el, $) {
594
1014
  const anchors = $el.find("a");
595
1015
  if (anchors.length !== 1) return { match: false };
596
1016
  const anchor = $(anchors[0]);
1017
+ if (!isWholeCellAnchor($el, anchor)) return { match: false };
597
1018
  if (looksLikeButton(getStyles$1(anchor))) return {
598
1019
  match: true,
599
1020
  anchor
@@ -610,14 +1031,22 @@ function isButtonCell($el, $) {
610
1031
  * Converts a single content-bearing element (heading / paragraph / image /
611
1032
  * anchor-as-button / divider) to a Templatical block.
612
1033
  *
1034
+ * A container whose entire meaningful content is one block-level element is
1035
+ * unwrapped first, so the element inside is what gets mapped and named in the
1036
+ * report — see `resolveWrappedBlock`. Only a heading can come back from that,
1037
+ * which is why the heading branch is the only one taking the resolved styles:
1038
+ * for every other branch the resolved element is the one handed in, so its own
1039
+ * styles are what `styles` already holds.
1040
+ *
613
1041
  * Returns `null` for elements that do not contain any meaningful content
614
1042
  * (the caller should skip them).
615
1043
  */
616
1044
  function convertElement($el, $) {
617
- const tag = tagOf($el[0]);
1045
+ const { $el: $target, styles } = resolveWrappedBlock($el, $);
1046
+ const tag = tagOf($target[0]);
618
1047
  if (!tag) return null;
619
1048
  if (HEADING_TAGS.has(tag)) return {
620
- block: convertHeading($el),
1049
+ block: convertHeading($target, styles),
621
1050
  entry: {
622
1051
  sourceTag: tag,
623
1052
  templaticalBlockType: "title",
@@ -625,7 +1054,7 @@ function convertElement($el, $) {
625
1054
  }
626
1055
  };
627
1056
  if (tag === "img") return {
628
- block: convertImage($el),
1057
+ block: convertImage($target),
629
1058
  entry: {
630
1059
  sourceTag: tag,
631
1060
  templaticalBlockType: "image",
@@ -633,8 +1062,8 @@ function convertElement($el, $) {
633
1062
  }
634
1063
  };
635
1064
  if (tag === "a") {
636
- if (looksLikeButton(getStyles$1($el))) return {
637
- block: convertButton($el),
1065
+ if (looksLikeButton(styles)) return {
1066
+ block: convertButton($target),
638
1067
  entry: {
639
1068
  sourceTag: tag,
640
1069
  templaticalBlockType: "button",
@@ -642,7 +1071,7 @@ function convertElement($el, $) {
642
1071
  }
643
1072
  };
644
1073
  return {
645
- block: convertParagraph($el),
1074
+ block: convertParagraph($target),
646
1075
  entry: {
647
1076
  sourceTag: tag,
648
1077
  templaticalBlockType: "paragraph",
@@ -652,7 +1081,7 @@ function convertElement($el, $) {
652
1081
  };
653
1082
  }
654
1083
  if (tag === "hr") return {
655
- block: convertDivider($el),
1084
+ block: convertDivider($target),
656
1085
  entry: {
657
1086
  sourceTag: tag,
658
1087
  templaticalBlockType: "divider",
@@ -660,9 +1089,9 @@ function convertElement($el, $) {
660
1089
  }
661
1090
  };
662
1091
  if (TEXT_TAGS.has(tag)) {
663
- if (!($el.text() ?? "").trim() && $el.find("img, a").length === 0) return null;
1092
+ if (!hasRenderedContent($target)) return null;
664
1093
  return {
665
- block: convertParagraph($el),
1094
+ block: convertParagraph($target),
666
1095
  entry: {
667
1096
  sourceTag: tag,
668
1097
  templaticalBlockType: "paragraph",
@@ -671,7 +1100,7 @@ function convertElement($el, $) {
671
1100
  };
672
1101
  }
673
1102
  return {
674
- block: convertHtmlFallback($el, $, `Unsupported element <${tag}>: preserved as raw HTML`),
1103
+ block: convertHtmlFallback($target, $, `Unsupported element <${tag}>: preserved as raw HTML`),
675
1104
  entry: {
676
1105
  sourceTag: tag,
677
1106
  templaticalBlockType: "html",
@@ -681,6 +1110,211 @@ function convertElement($el, $) {
681
1110
  };
682
1111
  }
683
1112
  //#endregion
1113
+ //#region src/column-ratio.ts
1114
+ /**
1115
+ * The share of a section's width each column of a layout occupies.
1116
+ *
1117
+ * These are the percentages `@templatical/renderer` emits for the same layout
1118
+ * (`getWidthPercentages`), and they have to stay equal to them: this table is
1119
+ * what a declared ratio is snapped *to*, so a divergence would have the
1120
+ * importer choose `2-1` for a row the renderer then renders at some other
1121
+ * split — a silent disagreement with no failing surface between the two
1122
+ * packages. `@templatical/renderer` is a devDependency of this package and
1123
+ * must stay one (the importer has no runtime need of it), so the values are
1124
+ * copied rather than imported, and the agreement is asserted by the
1125
+ * renderer-agreement test in `column-ratio.test.ts`.
1126
+ *
1127
+ * Keyed by every `ColumnLayout`, so a new layout has to appear here before it
1128
+ * can be snapped to — and the candidate list below is derived from this table
1129
+ * rather than from a hand-written map of count to layouts, so adding one is a
1130
+ * single edit.
1131
+ *
1132
+ * Exported for that agreement test alone, which compares it against
1133
+ * `getWidthPercentages` value by value. Going through `resolveColumnRatio`
1134
+ * instead cannot do the job: a drift smaller than the snap tolerance still
1135
+ * snaps to the same layout, so a 50/50 target quietly moved to 55/45 passes
1136
+ * every behavioural case.
1137
+ */
1138
+ const LAYOUT_SHARES = {
1139
+ "1": [100],
1140
+ "2": [50, 50],
1141
+ "3": [
1142
+ 33.33,
1143
+ 33.33,
1144
+ 33.34
1145
+ ],
1146
+ "1-2": [33.33, 66.67],
1147
+ "2-1": [66.67, 33.33]
1148
+ };
1149
+ /**
1150
+ * How far, in percentage points on the worst column, a declared ratio may sit
1151
+ * from a layout and still be read as that layout.
1152
+ *
1153
+ * Measured against the corpus rather than chosen for roundness. The widest
1154
+ * real match is mailchimp's `width="350"` / `width="190"` sidebar row at
1155
+ * 64.8 / 35.2, **1.9pp** from `2-1`; the nearest declared ratio that must
1156
+ * *not* match is an 80 / 20 split at **13.3pp** from the same layout. Six sits
1157
+ * 3.2x above the first and 2.2x below the second.
1158
+ *
1159
+ * It also has to stay under 8.33, which is half the 16.67pp gap between the
1160
+ * two closest two-column layouts (`2` at 50/50 and `2-1` at 66.67/33.33):
1161
+ * above that the snap windows overlap and one ratio matches two layouts.
1162
+ */
1163
+ const SNAP_TOLERANCE_PP = 6;
1164
+ /**
1165
+ * `mj-column-per-66-67` → 66.67%, `mj-column-per-50` → 50%. Compiled MJML
1166
+ * carries a column's share in its class name and nowhere else — every column
1167
+ * div also gets `style="width:100%"` — so this signal has to outrank the
1168
+ * inline style or every MJML layout reads as an equal split.
1169
+ */
1170
+ const MJ_COLUMN_PERCENT = /(?:^|\s)mj-column-per-(\d+)(?:-(\d+))?(?=\s|$)/;
1171
+ /** `mj-column-px-350` → 350px, MJML's fixed-width column variant. */
1172
+ const MJ_COLUMN_PIXELS = /(?:^|\s)mj-column-px-(\d+)(?=\s|$)/;
1173
+ function parseColumnClass(className) {
1174
+ if (!className) return null;
1175
+ const percent = className.match(MJ_COLUMN_PERCENT);
1176
+ if (percent) {
1177
+ const fraction = percent[2] ? `.${percent[2]}` : "";
1178
+ return usableWidth({
1179
+ unit: "%",
1180
+ value: parseFloat(`${percent[1]}${fraction}`)
1181
+ });
1182
+ }
1183
+ const pixels = className.match(MJ_COLUMN_PIXELS);
1184
+ if (pixels) return usableWidth({
1185
+ unit: "px",
1186
+ value: parseFloat(pixels[1])
1187
+ });
1188
+ return null;
1189
+ }
1190
+ /**
1191
+ * A length that states a share of its row, or `null`.
1192
+ *
1193
+ * A bare number is px, which is what a legacy `width="350"` attribute means.
1194
+ * Anything else — `auto`, a calc, an em length — states no share.
1195
+ */
1196
+ function parseWidth(raw) {
1197
+ if (raw === void 0) return null;
1198
+ const match = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(px|%)?$/);
1199
+ if (!match) return null;
1200
+ return usableWidth({
1201
+ unit: match[2] === "%" ? "%" : "px",
1202
+ value: parseFloat(match[1])
1203
+ });
1204
+ }
1205
+ /**
1206
+ * Drops a width that cannot express a share of its row.
1207
+ *
1208
+ * `100%` is the trap this exists for: every compiled-MJML column div and every
1209
+ * Cerberus stack column carries `width:100%` beside its real cap, and a cell
1210
+ * claiming the whole row while sharing it with another cell is stating "fill
1211
+ * what is left" rather than a ratio. Normalising it reads two such columns as
1212
+ * an equal split, and one such column beside a 200px one as 33/67 — a ratio
1213
+ * the source never declared. A `100px` width is a real one, so the rule is on
1214
+ * the percentage unit alone.
1215
+ */
1216
+ function usableWidth(width) {
1217
+ if (!(width.value > 0)) return null;
1218
+ if (width.unit === "%" && width.value >= 100) return null;
1219
+ return width;
1220
+ }
1221
+ /**
1222
+ * The width a column host declares, read from the strongest signal it carries.
1223
+ *
1224
+ * Priority is by how specifically each signal states a *column's* share: an
1225
+ * `mj-column-*` class names it outright; `max-width` is the cap that decides
1226
+ * an inline-block column's rendered width and so wins over the `width:100%`
1227
+ * sitting beside it; a `width` style and the legacy `width` attribute come
1228
+ * last, style before attribute because CSS beats a presentational attribute in
1229
+ * every browser.
1230
+ *
1231
+ * Takes primitives rather than a Cheerio node so the whole ratio decision is
1232
+ * testable without a DOM.
1233
+ */
1234
+ function readColumnWidth(className, styles, widthAttr) {
1235
+ return parseColumnClass(className) ?? parseWidth(styles["max-width"]) ?? parseWidth(styles.width) ?? parseWidth(widthAttr);
1236
+ }
1237
+ /**
1238
+ * The declared widths as percentages of their row, or `null` when the row
1239
+ * declares no ratio at all.
1240
+ *
1241
+ * Every column must carry a width, in one unit: a single share of an unknown
1242
+ * total states no ratio, and neither does a px width beside a percentage. Both
1243
+ * are refused rather than guessed, because the fallback — the layout the
1244
+ * column count already gives — is correct, and a guess is not.
1245
+ */
1246
+ function normalizeShares(widths) {
1247
+ if (widths.length === 0) return null;
1248
+ const first = widths[0];
1249
+ if (!first) return null;
1250
+ if (widths.some((width) => !width || width.unit !== first.unit)) return null;
1251
+ const values = widths.map((width) => width.value);
1252
+ const total = values.reduce((sum, value) => sum + value, 0);
1253
+ if (!(total > 0)) return null;
1254
+ return values.map((value) => value / total * 100);
1255
+ }
1256
+ /** The layouts that hold exactly this many columns. */
1257
+ function candidateLayouts(count) {
1258
+ return Object.keys(LAYOUT_SHARES).filter((layout) => LAYOUT_SHARES[layout].length === count);
1259
+ }
1260
+ /** How far the worst column of `shares` sits from `layout`, in points. */
1261
+ function deviation(shares, layout) {
1262
+ const target = LAYOUT_SHARES[layout];
1263
+ return Math.max(...shares.map((share, i) => Math.abs(share - target[i])));
1264
+ }
1265
+ /** The layout `shares` states, or `null` when none is within tolerance. */
1266
+ function snapToLayout(shares) {
1267
+ let best = null;
1268
+ let bestDeviation = Number.POSITIVE_INFINITY;
1269
+ for (const layout of candidateLayouts(shares.length)) {
1270
+ const distance = deviation(shares, layout);
1271
+ if (distance < bestDeviation) {
1272
+ best = layout;
1273
+ bestDeviation = distance;
1274
+ }
1275
+ }
1276
+ return best !== null && bestDeviation <= SNAP_TOLERANCE_PP ? best : null;
1277
+ }
1278
+ function formatShare(share) {
1279
+ return `${Math.round(share * 10) / 10}%`;
1280
+ }
1281
+ /**
1282
+ * What snapping could not express, named as the reader sees it.
1283
+ *
1284
+ * "equal columns" is true because the layouts a count alone produces — `2` and
1285
+ * `3` — are both equal splits; `resolveColumnRatio` is the only caller and
1286
+ * never reaches here for any other. Naming the layout's own percentages
1287
+ * instead would leak the renderer's 33.34 rounding column into a report.
1288
+ */
1289
+ function describeRatioLoss(shares) {
1290
+ return `Column widths ${shares.map(formatShare).join(" / ")} have no Templatical equivalent. The section was imported as ${shares.length} equal columns.`;
1291
+ }
1292
+ /**
1293
+ * The layout a row's declared widths choose, given the layout its column count
1294
+ * already produced.
1295
+ *
1296
+ * Widths decide the *ratio* and never the count: `counted` fixes how many
1297
+ * columns there are, and the only layouts considered are the ones holding
1298
+ * exactly that many. A row declaring nothing, declaring only some of its
1299
+ * columns, or mixing units keeps `counted` and reports nothing — there is no
1300
+ * observed ratio to have lost. A ratio outside tolerance keeps `counted` too,
1301
+ * and names itself in a note, which is the only outcome that is a downgrade.
1302
+ *
1303
+ * Call it only for a `counted` that has columns to choose between. A merged
1304
+ * row (`"1"`) has none, and describing the ratio of columns the section no
1305
+ * longer has would contradict the merge note that row already carries.
1306
+ */
1307
+ function resolveColumnRatio(widths, counted) {
1308
+ const shares = normalizeShares(widths);
1309
+ if (!shares) return { layout: counted };
1310
+ const snapped = snapToLayout(shares);
1311
+ if (snapped) return { layout: snapped };
1312
+ return {
1313
+ layout: counted,
1314
+ note: describeRatioLoss(shares)
1315
+ };
1316
+ }
1317
+ //#endregion
684
1318
  //#region src/section-builder.ts
685
1319
  function emptyPadding$1() {
686
1320
  return {
@@ -763,6 +1397,262 @@ function resolveColumnLayout(cellCount, warnings) {
763
1397
  warnings.push(`Row with ${cellCount} columns was flattened to a single column. Templatical supports up to 3 columns per section.`);
764
1398
  return "1";
765
1399
  }
1400
+ /**
1401
+ * The one cell a row's content sits in, when every other cell of that row is
1402
+ * chrome rather than a column — or `null` when the row is a layout row in its
1403
+ * own right.
1404
+ *
1405
+ * Table-based email centres a fixed-width body by flanking it with blank
1406
+ * cells, and Foundation-derived markup pads a row out with a blank `expander`
1407
+ * cell. Counting cells reads both as columns: leemunroe's template, the
1408
+ * most-copied table email there is, imported as a three-column section with
1409
+ * the entire email crushed into the middle third and two empty columns beside
1410
+ * it. That is worse than the single column a cell count could never have
1411
+ * produced, so it is the one place a row's cell count is not the column count.
1412
+ *
1413
+ * The signal is content, never width: a cell is chrome when it holds nothing
1414
+ * a reader sees (`isBlankCell`), which covers a `&nbsp;` gutter and an empty
1415
+ * `expander` alike, and covers a blank cell stating a height — a horizontal
1416
+ * gutter's height says nothing about the row.
1417
+ *
1418
+ * Two constraints, both hazards a relaxed version would reintroduce:
1419
+ *
1420
+ * - Exactly one cell may carry content. Two filled cells and a blank third
1421
+ * is a grid with an empty slot, and collapsing it would re-flow the filled
1422
+ * columns from thirds to halves.
1423
+ * - A row with content in no cell at all is left alone. Its cells sit side by
1424
+ * side, so it states one gap per column rather than a stack of them, and
1425
+ * merging them would add their heights and invent vertical space.
1426
+ *
1427
+ * This rule is coupled to the container descent in `packagingTablesOf`, and
1428
+ * neither is complete without the other: the descent reaches Foundation's
1429
+ * layout rows, whose blank `expander` cell then reads as a second column
1430
+ * holding nothing but a spacer. Removing this rule turns every one of those
1431
+ * rows into a phantom two-column section.
1432
+ */
1433
+ function centringCells(cells) {
1434
+ if (cells.length < 2) return null;
1435
+ const withContent = cells.filter(($cell) => !isBlankCell($cell));
1436
+ return withContent.length === 1 ? withContent : null;
1437
+ }
1438
+ /**
1439
+ * Whether the markup below a layout container states columns anywhere: a row
1440
+ * with two or more cells carrying content.
1441
+ *
1442
+ * This gates the container descent below, and only there — the two content
1443
+ * walks descend a container unconditionally, which is right for them because
1444
+ * they convert its children in place. The packaging descent *promotes* the
1445
+ * rows it reaches to sections of their own, so it needs evidence that those
1446
+ * rows are layout rather than stacked content.
1447
+ *
1448
+ * Without the evidence test the descent shatters a section into one section
1449
+ * per block, wherever a single cell holds one container per column instead of
1450
+ * one cell per column. Measured on compiled MJML — a `div.mj-column-per-*`
1451
+ * per column inside one `<td>` — a five-section email imported as thirteen
1452
+ * one-block sections, and Cerberus's hybrid template went from 14 sections to
1453
+ * 24. Neither loses text; both lose the grouping the source stated, and a
1454
+ * cell holding parallel containers has no column count below it to recover in
1455
+ * exchange.
1456
+ *
1457
+ * Content-bearing cells, not cells: a blank-flanked row is one column, which
1458
+ * is what `centringCells` reads it as. Counting bare cells here would make
1459
+ * this the second answer in the file to "is this row a set of columns?".
1460
+ */
1461
+ function declaresColumnsBelow($el, $) {
1462
+ let found = false;
1463
+ $el.find("tr").each((_, row) => {
1464
+ if (found) return;
1465
+ if (getDirectCells($(row), $).filter(($cell) => !isBlankCell($cell)).length > 1) found = true;
1466
+ });
1467
+ return found;
1468
+ }
1469
+ /**
1470
+ * The tables that make up a cell's entire meaningful content, or `null` when
1471
+ * the cell holds anything else.
1472
+ *
1473
+ * Anything that is not a table has to leave nothing behind for the cell to
1474
+ * count as packaging: whitespace, comments, and inline formatting carrying no
1475
+ * text all produce no block, so a cell holding tables and a bare `<br>`
1476
+ * qualifies while one holding a heading beside its table does not.
1477
+ *
1478
+ * A layout container is descended rather than refused, through the same
1479
+ * `isTableContainer` predicate the two content walks use — plus the evidence
1480
+ * test above, which is what keeps the descent from shattering a section whose
1481
+ * columns are sibling containers in one cell. Refusing a container outright
1482
+ * made a `<div>` or a `<center>` between the cell and the layout table enough
1483
+ * to defeat the descent, and the cell walk then flattened the whole subtree
1484
+ * into one column: ZURB Inky's output, which wraps every email in a
1485
+ * `<center>`, imported as a single one-column section.
1486
+ *
1487
+ * The recursion is what keeps the guard below intact through the wrapper: a
1488
+ * container holding prose beside its table answers `null`, which propagates,
1489
+ * and the row keeps the section that carries that prose.
1490
+ *
1491
+ * Bounded by DOM depth — a container is descended only when it holds a table,
1492
+ * and each step moves to a child.
1493
+ */
1494
+ function packagingTablesOf($cell, $) {
1495
+ const tables = [];
1496
+ let inlineText = "";
1497
+ for (const node of $cell.contents().toArray()) {
1498
+ if (isInlineContent(node)) {
1499
+ inlineText += $(node).text();
1500
+ continue;
1501
+ }
1502
+ if (!isTag(node)) continue;
1503
+ const tag = node.tagName.toLowerCase();
1504
+ const $child = $(node);
1505
+ if (tag === "table") {
1506
+ tables.push($child);
1507
+ continue;
1508
+ }
1509
+ if (isTableContainer($child, tag) && declaresColumnsBelow($child, $)) {
1510
+ const nested = packagingTablesOf($child, $);
1511
+ if (nested === null) return null;
1512
+ tables.push(...nested);
1513
+ continue;
1514
+ }
1515
+ return null;
1516
+ }
1517
+ if (tables.length === 0) return null;
1518
+ if (inlineText.trim() !== "") return null;
1519
+ return tables;
1520
+ }
1521
+ /**
1522
+ * The tables a row is merely packaging for, or `null` when the row is layout
1523
+ * in its own right.
1524
+ *
1525
+ * Table-based email buries the row that states the real column count under
1526
+ * one-cell wrapper tables, and a section emitted for a wrapper resolves
1527
+ * `columns` from that single cell — reporting one column for a row that has
1528
+ * two or three. Descending to the table inside reads the count off the row
1529
+ * that actually declares it, which is counting cells rather than inferring a
1530
+ * layout from widths or class names.
1531
+ *
1532
+ * Two conditions keep the descent from losing anything, and both are hazards
1533
+ * a future edit would reintroduce by relaxing them:
1534
+ *
1535
+ * - The cell's meaningful content must *be* the tables. Descending discards
1536
+ * the row, so a heading or an image beside the table would be dropped.
1537
+ * - The row must carry no background and no padding. The section it emits is
1538
+ * the only carrier for those, so descending past a styled row would drop
1539
+ * the band it paints.
1540
+ */
1541
+ function packagingRowTables($row, cells, $) {
1542
+ if (cells.length !== 1) return null;
1543
+ const rowStyles = getStyles($row);
1544
+ if (parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background)) return null;
1545
+ const padding = readPaddingFromStyles(rowStyles);
1546
+ if (padding.top || padding.right || padding.bottom || padding.left) return null;
1547
+ return packagingTablesOf(cells[0], $);
1548
+ }
1549
+ /**
1550
+ * The report entry for the section a layout row produces.
1551
+ *
1552
+ * Whether the row was downgraded is read off the slots that were actually
1553
+ * built: one slot per cell means every cell kept its own column, while fewer
1554
+ * slots than cells means `resolveColumnLayout` merged them. Deciding it by
1555
+ * comparing the cell count against the column ceiling instead would be a
1556
+ * second source of truth for that ceiling and would start lying the moment
1557
+ * the resolver changed. The ceiling appears only in the note's wording, where
1558
+ * it explains the merge to a reader rather than driving the branch.
1559
+ *
1560
+ * A faithful row gets no `note` at all. Attaching one unconditionally makes
1561
+ * "nothing was lost" indistinguishable from a downgrade for a caller that
1562
+ * filters on `note`, which is the whole reason the field is optional.
1563
+ *
1564
+ * `cellCount` is the row's column-bearing cells, not every cell it has: a
1565
+ * centring row's gutters were never columns, so counting them would report a
1566
+ * three-into-one merge for a row that always stated one column.
1567
+ */
1568
+ function sectionEntry(cellCount, slotCount, ratioNote) {
1569
+ if (slotCount !== cellCount) return {
1570
+ sourceTag: "tr",
1571
+ templaticalBlockType: "section",
1572
+ status: "approximated",
1573
+ note: `Row of ${cellCount} cells was merged into a single column. Templatical sections hold at most 3 columns.`
1574
+ };
1575
+ if (ratioNote) return {
1576
+ sourceTag: "tr",
1577
+ templaticalBlockType: "section",
1578
+ status: "approximated",
1579
+ note: ratioNote
1580
+ };
1581
+ return {
1582
+ sourceTag: "tr",
1583
+ templaticalBlockType: "section",
1584
+ status: "converted"
1585
+ };
1586
+ }
1587
+ /**
1588
+ * The report entry for a nested row whose section wrapper was dropped, or
1589
+ * `null` when dropping it lost nothing.
1590
+ *
1591
+ * `packages/core/src/editor.ts` forbids a section inside a column because MJML
1592
+ * forbids `mj-section` there, so a layout table reached from a cell has to
1593
+ * flatten — the columns are gone regardless. One cell has no columns to lose,
1594
+ * and reporting that as a downgrade would fill the report with entries for a
1595
+ * non-event.
1596
+ *
1597
+ * The count is the row's column-bearing cells, for the same reason
1598
+ * `sectionEntry`'s is: a centring row flattened into a parent column lost
1599
+ * nothing, so it must report nothing.
1600
+ */
1601
+ function flattenedRowEntry(cellCount) {
1602
+ if (cellCount <= 1) return null;
1603
+ return {
1604
+ sourceTag: "tr",
1605
+ templaticalBlockType: null,
1606
+ status: "approximated",
1607
+ note: `Nested row of ${cellCount} cells lost its columns. A Templatical section cannot nest inside a column, so its cells were merged into the surrounding column.`
1608
+ };
1609
+ }
1610
+ /**
1611
+ * The elements that each carry one of the row's columns.
1612
+ *
1613
+ * A cell holding a column set is replaced by that set, so the count comes from
1614
+ * the divs rather than from the one cell around them. Only a single layout
1615
+ * cell is considered: columns inside a column are not representable, so a
1616
+ * multi-cell row keeps its cells and can never have its count *reduced* by
1617
+ * this rule.
1618
+ *
1619
+ * Asked after `centringCells`, which is what lets a gutter-flanked row whose
1620
+ * middle cell holds a column set still be read as that set.
1621
+ */
1622
+ function columnHostsOf(layoutCells, $) {
1623
+ if (layoutCells.length === 1) {
1624
+ const containers = columnDivsOf(layoutCells[0], $);
1625
+ if (containers) return containers.map(($el) => ({
1626
+ $el,
1627
+ kind: "container"
1628
+ }));
1629
+ }
1630
+ return layoutCells.map(($el) => ({
1631
+ $el,
1632
+ kind: "cell"
1633
+ }));
1634
+ }
1635
+ /**
1636
+ * The blocks a column host contributes.
1637
+ *
1638
+ * A promoted container takes the content walk rather than the cell walk,
1639
+ * which is the same walk a container reached from inside a cell already gets
1640
+ * — so promoting one changes which slot its blocks land in and nothing about
1641
+ * how they convert. The two early returns the cell walk adds are about cells
1642
+ * specifically — `isSpacerCell` reads a `<td height>`, and `isButtonCell`
1643
+ * reads the cell-level styling table-based email wraps a call to action in —
1644
+ * and `looksLikeButton` answers true for `display: inline-block`, which every
1645
+ * column container carries. Handing a container to the cell walk would turn a
1646
+ * column whose content is one link into a single button block and drop
1647
+ * everything the column's own table holds.
1648
+ */
1649
+ function extractHostBlocks(host, $, entries, warnings) {
1650
+ return host.kind === "cell" ? extractCellBlocks(host.$el, $, entries, warnings) : extractContentBlocks(host.$el, $, entries, warnings);
1651
+ }
1652
+ /** The width an element declares, from the strongest signal it carries. */
1653
+ function readDeclaredWidth($el) {
1654
+ return readColumnWidth($el.attr("class"), getStyles($el), $el.attr("width"));
1655
+ }
766
1656
  function extractCellBlocks($cell, $, entries, warnings) {
767
1657
  if (isSpacerCell($cell)) {
768
1658
  entries.push({
@@ -781,24 +1671,34 @@ function extractCellBlocks($cell, $, entries, warnings) {
781
1671
  });
782
1672
  return [buildCellButton($cell, btn.anchor)];
783
1673
  }
1674
+ return extractContentBlocks($cell, $, entries, warnings);
1675
+ }
1676
+ /**
1677
+ * The blocks an element's child nodes produce, for an element that holds
1678
+ * content rather than being content itself: a table cell, or a layout
1679
+ * container descended from one.
1680
+ *
1681
+ * Node classification — bare text and inline markup grouped into runs, a
1682
+ * prose anchor folded into the run around it, comments passed over without
1683
+ * splitting one — is `walkContentNodes`' half, shared with the body walk in
1684
+ * `converter.ts`. What is left here is the half that differs: inside a cell a
1685
+ * nested table flattens into the surrounding column, where at body level it
1686
+ * becomes a section of its own.
1687
+ */
1688
+ function extractContentBlocks($host, $, entries, warnings) {
784
1689
  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() ?? "";
1690
+ walkContentNodes($host, $, ({ block, entry }) => {
1691
+ entries.push(entry);
1692
+ blocks.push(block);
1693
+ }, ($child, tag) => {
798
1694
  if (tag === "table") {
799
1695
  const inner = processTable($child, $, entries, warnings, true);
800
1696
  blocks.push(...inner);
801
- continue;
1697
+ return;
1698
+ }
1699
+ if (isTableContainer($child, tag)) {
1700
+ blocks.push(...extractContentBlocks($child, $, entries, warnings));
1701
+ return;
802
1702
  }
803
1703
  if (tag === "a" && looksLikeButton(getStyles($child))) {
804
1704
  const r = convertElement($child, $);
@@ -806,14 +1706,14 @@ function extractCellBlocks($cell, $, entries, warnings) {
806
1706
  entries.push(r.entry);
807
1707
  blocks.push(r.block);
808
1708
  }
809
- continue;
1709
+ return;
810
1710
  }
811
1711
  const r = convertElement($child, $);
812
1712
  if (r) {
813
1713
  entries.push(r.entry);
814
1714
  blocks.push(r.block);
815
1715
  }
816
- }
1716
+ });
817
1717
  return blocks;
818
1718
  }
819
1719
  /**
@@ -839,22 +1739,35 @@ function processTable($table, $, entries, warnings, flattenInline = false) {
839
1739
  for (const $row of rows) {
840
1740
  const cells = getDirectCells($row, $);
841
1741
  if (cells.length === 0) continue;
842
- const layout = resolveColumnLayout(cells.length, warnings);
1742
+ const packaging = packagingRowTables($row, cells, $);
1743
+ if (packaging) {
1744
+ for (const $inner of packaging) sections.push(...processTable($inner, $, entries, warnings, flattenInline));
1745
+ continue;
1746
+ }
1747
+ const hosts = columnHostsOf(centringCells(cells) ?? cells, $);
1748
+ const countedLayout = resolveColumnLayout(hosts.length, warnings);
843
1749
  let columnsBlocks;
844
- if (layout === "1") {
1750
+ if (countedLayout === "1") {
845
1751
  const merged = [];
846
- for (const $cell of cells) merged.push(...extractCellBlocks($cell, $, entries, warnings));
1752
+ for (const host of hosts) merged.push(...extractHostBlocks(host, $, entries, warnings));
847
1753
  columnsBlocks = [merged];
848
- } else columnsBlocks = cells.map(($cell) => extractCellBlocks($cell, $, entries, warnings));
1754
+ } else columnsBlocks = hosts.map((host) => extractHostBlocks(host, $, entries, warnings));
849
1755
  if (flattenInline) {
1756
+ const dropped = flattenedRowEntry(hosts.length);
1757
+ if (dropped) entries.push(dropped);
850
1758
  for (const col of columnsBlocks) sections.push(...col);
851
1759
  continue;
852
1760
  }
1761
+ const ratio = countedLayout === "1" ? {
1762
+ layout: countedLayout,
1763
+ note: void 0
1764
+ } : resolveColumnRatio(hosts.map((host) => readDeclaredWidth(host.$el)), countedLayout);
853
1765
  const rowStyles = getStyles($row);
854
1766
  const bgColor = parseColor(rowStyles["background-color"]) || parseColor(rowStyles.background);
855
1767
  const padding = readPaddingFromStyles(rowStyles);
1768
+ entries.push(sectionEntry(hosts.length, columnsBlocks.length, ratio.note));
856
1769
  sections.push(createSectionBlock({
857
- columns: layout,
1770
+ columns: ratio.layout,
858
1771
  children: columnsBlocks,
859
1772
  styles: {
860
1773
  padding,
@@ -903,9 +1816,21 @@ function extractSettings($) {
903
1816
  }
904
1817
  /**
905
1818
  * Wrap a list of free-floating blocks (those produced by top-level non-table
906
- * elements) in a single one-column section.
1819
+ * elements) in a single one-column section, and report the section.
1820
+ *
1821
+ * The section corresponds to no source element, so the report names `body` and
1822
+ * says the section is synthetic — otherwise a caller counting sections against
1823
+ * the rows it can see in the source finds one it cannot account for. Nothing is
1824
+ * lost on this path: every loose block keeps its order inside the one column,
1825
+ * which is why the status is `converted` rather than an approximation.
907
1826
  */
908
- function wrapInSection(blocks) {
1827
+ function wrapInSection(blocks, entries) {
1828
+ entries.push({
1829
+ sourceTag: "body",
1830
+ templaticalBlockType: "section",
1831
+ status: "converted",
1832
+ note: "Loose top-level content was grouped into a synthetic single-column section."
1833
+ });
909
1834
  return createSectionBlock({
910
1835
  columns: "1",
911
1836
  children: [blocks],
@@ -913,53 +1838,82 @@ function wrapInSection(blocks) {
913
1838
  });
914
1839
  }
915
1840
  /**
916
- * Walk top-level body children. Tables become sections; loose content
917
- * elements are accumulated and wrapped in a single one-column section.
1841
+ * Walk the body's child nodes. Tables become sections; loose content is
1842
+ * accumulated and wrapped in a single one-column section.
1843
+ *
1844
+ * Both walks below go through `walkContentNodes`, the same node
1845
+ * classification the cell walk uses, so bare text and inline markup at body
1846
+ * level and inside a layout container reach a rich-text block. A walk over
1847
+ * `children()` visits neither, which drops copy the source email displays —
1848
+ * `Lead<h2>H</h2>Trailing` imported as the heading alone.
918
1849
  */
919
1850
  function processBody($, entries, warnings) {
920
1851
  const blocks = [];
921
- const children = $("body").children().toArray();
1852
+ const $body = $("body");
922
1853
  let pendingLoose = [];
923
1854
  const flushLoose = () => {
924
1855
  if (pendingLoose.length > 0) {
925
- blocks.push(wrapInSection(pendingLoose));
1856
+ blocks.push(wrapInSection(pendingLoose, entries));
926
1857
  pendingLoose = [];
927
1858
  }
928
1859
  };
929
- for (const childEl of children) {
930
- const tag = childEl.tagName?.toLowerCase() ?? "";
931
- const $child = $(childEl);
1860
+ const collectLoose = ({ block, entry }) => {
1861
+ entries.push(entry);
1862
+ pendingLoose.push(block);
1863
+ };
1864
+ /**
1865
+ * Descend a layout container, taking its tables as sections and everything
1866
+ * else as loose content. A container holding another container descends
1867
+ * again, so a table reaches `processTable` at whatever depth the wrapper
1868
+ * markup buries it — MJML nests an outer body div around one div per
1869
+ * section around the section's table, and a single-level walk sees only the
1870
+ * middle div, which the block mapper turns into one paragraph swallowing
1871
+ * the entire table subtree.
1872
+ *
1873
+ * Bounded by DOM depth: a container is descended only when it holds a
1874
+ * table, and each step moves to a child.
1875
+ *
1876
+ * Declared here so every depth shares `pendingLoose` and `flushLoose`. A
1877
+ * per-level accumulator flushes at the wrong point and reorders the
1878
+ * document: content sitting before a nested table lands after it.
1879
+ */
1880
+ const walkContainer = ($container) => {
1881
+ walkContentNodes($container, $, collectLoose, ($inner, innerTag) => {
1882
+ if (innerTag === "table") {
1883
+ flushLoose();
1884
+ blocks.push(...processTable($inner, $, entries, warnings, false));
1885
+ return;
1886
+ }
1887
+ if (isTableContainer($inner, innerTag)) {
1888
+ walkContainer($inner);
1889
+ return;
1890
+ }
1891
+ const r = convertElement($inner, $);
1892
+ if (r) {
1893
+ entries.push(r.entry);
1894
+ pendingLoose.push(r.block);
1895
+ }
1896
+ });
1897
+ };
1898
+ walkContentNodes($body, $, collectLoose, ($child, tag) => {
932
1899
  if (tag === "table") {
933
1900
  flushLoose();
934
1901
  blocks.push(...processTable($child, $, entries, warnings, false));
935
- continue;
1902
+ return;
936
1903
  }
937
- if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none") continue;
938
- if ((tag === "div" || tag === "center" || tag === "main") && $child.find("table").length > 0) {
1904
+ if ((parseStyleAttribute($child.attr("style")).display ?? "").toLowerCase() === "none") return;
1905
+ if (isTableContainer($child, tag)) {
939
1906
  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
- });
1907
+ walkContainer($child);
954
1908
  flushLoose();
955
- continue;
1909
+ return;
956
1910
  }
957
1911
  const r = convertElement($child, $);
958
1912
  if (r) {
959
1913
  entries.push(r.entry);
960
1914
  pendingLoose.push(r.block);
961
1915
  }
962
- }
1916
+ });
963
1917
  flushLoose();
964
1918
  return blocks;
965
1919
  }