@seliseblocks/mailcraft 0.2.7 → 0.2.9

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.
@@ -88,7 +88,33 @@ function cellPadOf(tb) {
88
88
  * zero font. A zero font-size ALONE is not hidden: emails set it on
89
89
  * whitespace-collapsing wrappers around buttons and menus.
90
90
  */
91
+ /**
92
+ * A device-visibility wrapper, as a `vis` value -- or '' for ordinary content.
93
+ *
94
+ * Both this exporter's `mc-only-d`/`mc-only-m` and the `desktop_hide`/
95
+ * `mobile_hide` convention every other builder uses (BEE, Stripo, Mailchimp)
96
+ * are recognised, so a foreign template's device variants import as an
97
+ * editable property instead of being silently thrown away.
98
+ *
99
+ * This has to be consulted *before* `isHidden`, and that is the whole point:
100
+ * a mobile-only block is deliberately `display:none` in the base stylesheet
101
+ * so Classic Outlook -- which never reads a media query -- does not show it.
102
+ * The importer folds those base rules inline (core/css-cascade.js drops the
103
+ * `@media` block that would have un-hidden it), so without this the block
104
+ * looked exactly like a hidden preheader and was dropped on re-import,
105
+ * losing content the user had authored.
106
+ */
107
+ function visibilityOf(el) {
108
+ const cls = (el && el.getAttribute && el.getAttribute('class')) || '';
109
+ if (!cls) return '';
110
+ if (/\b(?:mc-only-m|mobile_hide|mobile-hide)\b/.test(cls)) return 'mobile';
111
+ if (/\b(?:mc-only-d|desktop_hide|desktop-hide)\b/.test(cls)) return 'desktop';
112
+ return '';
113
+ }
114
+
91
115
  function isHidden(el) {
116
+ // Hidden *on this device* is not hidden: see visibilityOf.
117
+ if (visibilityOf(el)) return false;
92
118
  const st = el.style;
93
119
  if (!st) return false;
94
120
  if (st.display === 'none' || st.visibility === 'hidden') return true;
@@ -124,6 +150,43 @@ function applyBgImage(rows, el) {
124
150
  return rows;
125
151
  }
126
152
 
153
+ /**
154
+ * A wrapper that paints one background image is one visual band: builders
155
+ * (Beefree, Stripo) write a hero section as `table.row[background-image]`
156
+ * holding a single-column stack of per-block tables, and letting the generic
157
+ * walk turn each block into its own row stamped the image onto every one --
158
+ * so the export re-drew the hero's top slice once per block instead of one
159
+ * image flowing behind the section. Folds those rows back into a single row,
160
+ * moving each row's padding onto its blocks (same convention as the
161
+ * block-table unwrap in `blocksFromNodes`). Deliberately conservative: only
162
+ * single-column rows with no styling of their own (a differing background,
163
+ * frame or image on a row is a real band boundary) are merged, so everything
164
+ * that isn't the hero shape walks exactly as before.
165
+ */
166
+ function mergeBandRows(rows, el) {
167
+ if (rows.length < 2 || !bgImageOf(el)) return rows;
168
+ const bg0 = rows[0].props.bg || '';
169
+ const plain = rows.every((r) => r.cols.length === 1
170
+ && !r.props.bgImage && !r.props.border && !r.props.radius && !r.props.shadow
171
+ && (r.props.bg || '') === bg0);
172
+ if (!plain) return rows;
173
+ const merged = mkRow([100]);
174
+ merged.props.py = 0; merged.props.px = 0; merged.props.gap = 0;
175
+ if (bg0) merged.props.bg = bg0;
176
+ merged.cols[0].blocks = rows.reduce((acc, r) => {
177
+ const padded = r.props.py || r.props.px || r.props.pt !== undefined;
178
+ r.cols[0].blocks.forEach((b) => {
179
+ if (padded && b.type !== 'button' && 'py' in b.props) {
180
+ b.props.py = r.props.py;
181
+ if ('px' in b.props) b.props.px = r.props.px;
182
+ }
183
+ acc.push(b);
184
+ });
185
+ return acc;
186
+ }, []);
187
+ return merged.cols[0].blocks.length ? [merged] : rows;
188
+ }
189
+
127
190
  /** Padding read from the longhands, which are populated by the `padding` shorthand too -- but not vice versa: builders that write `padding-top/-left/...` individually (Beefree et al.) read back an empty `style.padding`, which is how every one of their cells imported with zero padding. Carries the exact per-side values plus the averaged py/px pair for consumers that only have a pair to store. */
128
191
  function paddingOf(st) {
129
192
  if (!st) return null;
@@ -209,10 +272,25 @@ function classifyImage(el) {
209
272
  }
210
273
 
211
274
  function classifyButton(el) {
212
- let a = null; let outerAlign = '';
275
+ let a = null; let outerAlign = ''; let pillTable = null;
213
276
  if (el.tagName === 'A') a = el;
214
277
  else if (el.tagName === 'DIV' || el.tagName === 'TD') {
215
278
  a = onlyChild(el, 'A');
279
+ // The bulletproof shape: the anchor is wrapped in a one-cell table so
280
+ // Word has a `<td>` to paint and pad (this exporter emits that, and so
281
+ // does every hand-written bulletproof button). Reached through the
282
+ // wrapper rather than the cell because that is the node the row walker
283
+ // offers, and the wrapper is also what carries the alignment.
284
+ if (!a) {
285
+ pillTable = onlyChild(el, 'TABLE');
286
+ // Direct row cells only. A descendant search would count the `<td>`s
287
+ // inside whatever the cell contains, so a layout row holding one block
288
+ // could look like a one-cell button table -- or stop looking like one
289
+ // the moment the block itself contained a table.
290
+ const cells = pillTable ? pillTable.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td') : [];
291
+ if (cells.length === 1) a = onlyChild(cells[0], 'A');
292
+ if (!a) pillTable = null;
293
+ }
216
294
  if (a) outerAlign = el.style.textAlign || el.getAttribute('align') || '';
217
295
  }
218
296
  if (!a) return null;
@@ -221,11 +299,16 @@ function classifyButton(el) {
221
299
  // generators (Beefree et al.) leave the `<a>` bare and hang the background,
222
300
  // radius and padding on nested `<span>`s inside it. Whichever element
223
301
  // carries the background is the pill; padding may sit a level deeper still.
224
- const hasBg = (e) => !!(e.style && (e.style.backgroundColor || e.style.background));
225
- const pill = hasBg(a) ? a : Array.from(a.querySelectorAll('span')).find(hasBg);
302
+ const hasBg = (e) => !!(e.style && (e.style.backgroundColor || e.style.background)) || !!(e.getAttribute && e.getAttribute('bgcolor'));
303
+ // The cell is checked last and only as a fallback: an anchor that paints
304
+ // its own pill still describes the button best (that is where the radius
305
+ // and the label colour sit), and the cell is what carries them when the
306
+ // source put the paint and the padding on the `<td>` instead.
307
+ const cell = a.closest ? a.closest('td') : null;
308
+ const pill = hasBg(a) ? a : (Array.from(a.querySelectorAll('span')).find(hasBg) || (cell && hasBg(cell) ? cell : null));
226
309
  if (!pill) return null;
227
310
  const st = pill.style;
228
- let pad = paddingOf(a.style) || paddingOf(st);
311
+ let pad = paddingOf(a.style) || paddingOf(st) || (cell ? paddingOf(cell.style) : null);
229
312
  if (!pad) {
230
313
  const padded = Array.from(pill.querySelectorAll('span')).find((s) => paddingOf(s.style));
231
314
  if (padded) pad = paddingOf(padded.style);
@@ -235,8 +318,8 @@ function classifyButton(el) {
235
318
  const over = {
236
319
  label: (a.textContent || '').trim() || 'Button',
237
320
  href: a.getAttribute('href') || '#',
238
- bg: st.backgroundColor || st.background,
239
- color: st.color || a.style.color || '#ffffff',
321
+ bg: st.backgroundColor || st.background || (pill.getAttribute && pill.getAttribute('bgcolor')) || '',
322
+ color: a.style.color || st.color || '#ffffff',
240
323
  radius: radiusOf(st),
241
324
  py: (pad && pad.py) || 13,
242
325
  px: (pad && pad.px) || 26,
@@ -245,13 +328,23 @@ function classifyButton(el) {
245
328
  // about where the pill sits in the row -- that's the container's call,
246
329
  // so alignment is read starting at the parent, never at the anchor.
247
330
  align: outerAlign || textAlignOf(a.parentElement || a),
248
- full: a.style.display === 'block',
331
+ // In the one-cell shape the anchor is always `display:block` (it fills
332
+ // the padded cell), so full-width has to be read off the table instead --
333
+ // otherwise every bulletproof button imports as a full-width one.
334
+ full: pillTable
335
+ ? /100%/.test((pillTable.style && pillTable.style.width) || pillTable.getAttribute('width') || '')
336
+ : a.style.display === 'block',
249
337
  };
250
338
  const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
251
339
  if (size) over.size = size;
252
- // Outline buttons: transparent fill, the pill drawn by its border.
253
- const bw = borderSidesOf(st).width;
254
- if (bw) { over.borderW = bw; over.borderStyle = borderStyleOf(st); over.borderColor = borderColorOf(st) || over.color; }
340
+ // Outline buttons: transparent fill, the pill drawn by its border. The
341
+ // border is looked for on the cell as well as the pill, because in the
342
+ // one-cell shape the paint and the frame are on different elements -- the
343
+ // anchor is the pill (it carries the background) while the `<td>` draws the
344
+ // outline. Reading only the pill dropped `borderW` on every round trip.
345
+ const frame = borderSidesOf(st).width ? st : (cell && cell.style && borderSidesOf(cell.style).width ? cell.style : st);
346
+ const bw = borderSidesOf(frame).width;
347
+ if (bw) { over.borderW = bw; over.borderStyle = borderStyleOf(frame); over.borderColor = borderColorOf(frame) || over.color; }
255
348
  return blk('button', over);
256
349
  }
257
350
 
@@ -359,15 +452,29 @@ function classifyHeading(el) {
359
452
  const st = el.style;
360
453
  const over = { text: (el.textContent || '').trim(), level: el.tagName.toLowerCase() };
361
454
  const size = fontPx(st.fontSize); if (size) over.size = size;
362
- if (st.textAlign) over.align = st.textAlign;
363
- if (st.color) over.color = st.color;
455
+ // Color and alignment inherit in CSS, and hero sections declare them on
456
+ // the section `<td>` -- a white heading over a dark photo whose color
457
+ // lived on the cell imported in the default dark ink and vanished into
458
+ // the image. Own values still win; the walk up only fills the gaps,
459
+ // exactly like the text-run path.
460
+ const align = st.textAlign || inheritedStyle(el.parentElement, 'textAlign');
461
+ if (align) over.align = align;
462
+ const color = st.color || inheritedStyle(el.parentElement, 'color');
463
+ if (color) over.color = hexOf(color);
364
464
  if (st.fontWeight) over.weight = st.fontWeight;
365
465
  return blk('heading', over);
366
466
  }
367
467
 
368
468
  function classifyList(el) {
369
469
  if (el.tagName !== 'UL' && el.tagName !== 'OL') return null;
370
- const items = Array.from(el.children).filter((c) => c.tagName === 'LI').map((li) => li.innerHTML.trim());
470
+ // Through the same whitelist as a text run: this was the one classifier
471
+ // that carried raw source innerHTML into props, so classes, ids and event
472
+ // handlers (`onerror` on an <img> in a list item) rode into the canvas DOM
473
+ // and the export while every other path stripped them. The newline swap is
474
+ // because `items` is a one-fragment-per-line string -- a linebreak inside a
475
+ // source <li> would otherwise split it into two items on render.
476
+ const items = Array.from(el.children).filter((c) => c.tagName === 'LI')
477
+ .map((li) => cleanImportHtml(li.innerHTML).replace(/\n/g, ' '));
371
478
  if (!items.length) return null;
372
479
  return blk('list', { items: items.join('\n'), ordered: el.tagName === 'OL' });
373
480
  }
@@ -397,7 +504,18 @@ const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu
397
504
  function classifyNode(el) {
398
505
  for (const fn of CLASSIFIERS) {
399
506
  const b = fn(el);
400
- if (b) return b;
507
+ if (!b) continue;
508
+ // The class sits on the wrapper the exporter puts around each block, and
509
+ // on a foreign template it can be a level or two further out, so a short
510
+ // walk up finds it. Only ever set when a wrapper actually says so --
511
+ // absent means "all devices", which is what every ordinary block wants.
512
+ let n = el;
513
+ for (let i = 0; n && i < 4; i += 1) {
514
+ const v = visibilityOf(n);
515
+ if (v) { b.props.vis = v; break; }
516
+ n = n.parentElement;
517
+ }
518
+ return b;
401
519
  }
402
520
  return null;
403
521
  }
@@ -409,7 +527,18 @@ function isStructural(el) {
409
527
  /** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which for every block type shipped today (none define the `bBg/bBorder/bLine/bRadius/bPad` props `boxCss` reads) always resolves to a bare `<div style="margin:0">` -- a see-through spacing wrapper, not real content. Unwraps that one level so the classifiers see the block's own signature div directly; leaves any div that carries other styling (an intentionally-styled container someone pasted in) alone, since that's real content, not framework wrapper. */
410
528
  function unwrapBoxDiv(el) {
411
529
  if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
412
- const extra = Array.from(el.style).filter((prop) => prop.indexOf('margin') !== 0);
530
+ // A device-visibility wrapper is framework too, and the declarations that
531
+ // hide it are the framework's, not the author's: a mobile-only block is
532
+ // `display:none` in the base stylesheet so Classic Outlook never shows it,
533
+ // and css-cascade folds that inline on import. Without this exemption the
534
+ // wrapper looked like a deliberately styled container, never unwrapped, and
535
+ // the heading inside it came back as an untyped run of text. `classifyNode`
536
+ // reads the visibility off this same wrapper by walking back up, so the
537
+ // property survives the unwrap.
538
+ const framework = visibilityOf(el)
539
+ ? /^(?:margin|display|max-height|overflow|mso-)/
540
+ : /^margin/;
541
+ const extra = Array.from(el.style).filter((prop) => !framework.test(prop));
413
542
  if (extra.length) return el;
414
543
  const child = el.firstElementChild;
415
544
  if ((el.textContent || '') !== (child.textContent || '')) return el;
@@ -461,10 +590,30 @@ function blocksFromNodes(nodes) {
461
590
  const out = [];
462
591
  let buf = [];
463
592
  let bufFirstEl = null;
593
+ // The parent of a leading bare text node. A run with no element of its own
594
+ // (`<td style="font-size:30px;...">{{Code}}</td>`) still has real
595
+ // typography -- it lives on the ancestors, and with no bufFirstEl the
596
+ // inheritedStyle reads below were skipped wholesale, so the run imported
597
+ // at the theme default (a 30px/800 verification code became 16px plain).
598
+ let bufTextEl = null;
464
599
  const flush = () => {
465
600
  const html = cleanImportHtml(buf.join(''));
466
- if (html) {
601
+ // A run with nothing visible in it -- no text beyond whitespace/hair
602
+ // spaces, no image or line break (an `<nbsp;>` still counts as a blank
603
+ // line someone wrote) -- is markup residue (`<p style="margin:0"></p>`),
604
+ // not content; as a block it rendered as a phantom padded row.
605
+ const visible = html && (/<(img|br|hr)\b/i.test(html)
606
+ || html.replace(/<[^>]*>/g, '').replace(/[ \t\r\n\u2009\u200a\u200b]/g, '') !== '');
607
+ if (visible) {
467
608
  const over = { html };
609
+ if (!bufFirstEl && bufTextEl) {
610
+ const size = fontPx(inheritedStyle(bufTextEl, 'fontSize')); if (size) over.size = size;
611
+ const color = inheritedStyle(bufTextEl, 'color'); if (color) over.color = hexOf(color);
612
+ over.align = textAlignOf(bufTextEl);
613
+ const weight = inheritedStyle(bufTextEl, 'fontWeight'); if (weight) over.weight = weight;
614
+ const lh = lineHeightRatio(inheritedStyle(bufTextEl, 'lineHeight'), size);
615
+ if (lh) over.lh = lh;
616
+ }
468
617
  if (bufFirstEl) {
469
618
  // The block's *base* style: the first buffered element when it's a
470
619
  // block-level thing (a styled <p>/<div> speaks for the run), else its
@@ -490,7 +639,7 @@ function blocksFromNodes(nodes) {
490
639
  ) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
491
640
  if (runPad) { over.py = runPad.py; over.px = runPad.px; }
492
641
  const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
493
- const color = inheritedStyle(baseEl, 'color'); if (color) over.color = color;
642
+ const color = inheritedStyle(baseEl, 'color'); if (color) over.color = hexOf(color);
494
643
  over.align = textAlignOf(baseEl);
495
644
  const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
496
645
  const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
@@ -498,13 +647,16 @@ function blocksFromNodes(nodes) {
498
647
  }
499
648
  out.push(blk('text', over));
500
649
  }
501
- buf = []; bufFirstEl = null;
650
+ buf = []; bufFirstEl = null; bufTextEl = null;
502
651
  };
503
652
  nodes.forEach((n) => {
504
653
  if (n.nodeType === 3) {
505
654
  const markers = logicMarkersOf(n.textContent);
506
655
  if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
507
- if (n.textContent && n.textContent.trim()) buf.push(escapeText(n.textContent));
656
+ if (n.textContent && n.textContent.trim()) {
657
+ if (!bufFirstEl && !bufTextEl) bufTextEl = n.parentElement;
658
+ buf.push(escapeText(n.textContent));
659
+ }
508
660
  return;
509
661
  }
510
662
  if (n.nodeType !== 1) return;
@@ -557,10 +709,16 @@ function normalizeSpans(spans) {
557
709
  function spansFromCells(cells, tableWidthPx) {
558
710
  const parsed = cells.map((td) => {
559
711
  const sw = td.style.width || '';
712
+ // `max-width` before `width`, but only where `width` says nothing useful.
713
+ // A CSS-layout column is written `width:100%;max-width:50%` -- the 100%
714
+ // is the fluid instruction and the cap is the actual share of the row, so
715
+ // reading `width` first would score every column at 100.
716
+ const mw = td.style.maxWidth || '';
717
+ if (mw.endsWith('%') && (!sw || PX(sw) >= 100)) return { pct: PX(mw) };
560
718
  if (sw.endsWith('%')) return { pct: PX(sw) };
561
719
  const aw = td.getAttribute('width') || '';
562
720
  if (aw.endsWith('%')) return { pct: PX(aw) };
563
- const px = PX(sw) || PX(aw);
721
+ const px = PX(sw) || PX(aw) || (mw.endsWith('px') ? PX(mw) : 0);
564
722
  return px ? { px } : null;
565
723
  });
566
724
  if (parsed.every((p) => p && p.pct != null)) {
@@ -574,10 +732,140 @@ function spansFromCells(cells, tableWidthPx) {
574
732
  return spans;
575
733
  }
576
734
 
735
+ /*
736
+ * Columns that are not table cells.
737
+ *
738
+ * A row's columns do not have to be `<td>`s. MJML, and to a degree Unlayer and
739
+ * Stripo, emit them as sibling `<div>`s that sit side by side through
740
+ * `display:inline-block` and a percentage cap, with the table version hidden
741
+ * inside `<!--[if mso]>` conditionals for Outlook. Those comments are comments
742
+ * to a DOM parser, so all the walker ever saw was one `<td>` holding two
743
+ * divs -- and a real 50/50 row imported as two stacked full-width rows. The
744
+ * blocks all survived; the layout relationship did not.
745
+ *
746
+ * Returning the divs here hands them to exactly the same machinery the table
747
+ * path already uses -- `spansFromCells` for the widths, gap and gutter
748
+ * detection, per-column background and padding -- so there is no second
749
+ * column model, only a second way of recognising one.
750
+ *
751
+ * Deliberately conservative, because a wrong grouping is worse than none: a
752
+ * false positive welds unrelated sections into one row, which the user then
753
+ * has to take apart by hand. Every one of these has to hold.
754
+ */
755
+ /*
756
+ * A column's share of its row is not always in its inline style.
757
+ *
758
+ * MJML writes the column as `width:100%` inline and puts the real share in a
759
+ * class rule inside `@media only screen and (min-width:480px)` -- a *desktop*
760
+ * query, so it carries the layout rather than overriding it. `css-cascade.js`
761
+ * strips every at-rule before folding, by design (a mobile override cannot be
762
+ * represented in the model), which means that share never reaches the element
763
+ * and three columns looked like three widthless divs.
764
+ *
765
+ * So the stylesheets are read once per document for `.class { width: N% }`,
766
+ * from top-level rules and from `min-width` blocks -- never from `max-width`
767
+ * blocks, which are the mobile overrides the cascade is right to drop.
768
+ */
769
+ const CLASS_WIDTHS = new WeakMap();
770
+
771
+ function scanWidthRules(css, map) {
772
+ let i = 0;
773
+ while (i < css.length) {
774
+ const at = css.indexOf('@', i);
775
+ const plain = css.slice(i, at < 0 ? css.length : at);
776
+ plain.replace(/([^{}]+)\{([^{}]*)\}/g, (m0, sel, decl) => {
777
+ const w = decl.match(/(?:^|;)\s*width\s*:\s*([\d.]+)%/i);
778
+ if (w) {
779
+ sel.split(',').forEach((one) => {
780
+ const cm = one.trim().match(/^\.([-\w]+)$/);
781
+ if (cm && !map[cm[1]]) map[cm[1]] = parseFloat(w[1]);
782
+ });
783
+ }
784
+ return m0;
785
+ });
786
+ if (at < 0) break;
787
+ const brace = css.indexOf('{', at);
788
+ if (brace < 0) break;
789
+ const prelude = css.slice(at, brace);
790
+ let depth = 0;
791
+ let j = brace;
792
+ for (; j < css.length; j += 1) {
793
+ if (css[j] === '{') depth += 1;
794
+ else if (css[j] === '}') { depth -= 1; if (!depth) break; }
795
+ }
796
+ // `max-width` is a narrow-screen override, which says nothing about the
797
+ // desktop layout this is trying to recover.
798
+ if (!/max-width/i.test(prelude)) scanWidthRules(css.slice(brace + 1, j), map);
799
+ i = j + 1;
800
+ }
801
+ }
802
+
803
+ function classWidths(doc) {
804
+ if (!doc || !doc.querySelectorAll) return {};
805
+ const hit = CLASS_WIDTHS.get(doc);
806
+ if (hit) return hit;
807
+ const map = {};
808
+ Array.from(doc.querySelectorAll('style')).forEach((st) => {
809
+ scanWidthRules(String(st.textContent || '').replace(/\/\*[\s\S]*?\*\//g, ''), map);
810
+ });
811
+ CLASS_WIDTHS.set(doc, map);
812
+ return map;
813
+ }
814
+
815
+ function inlineColumnGroup(nodes) {
816
+ const kids = Array.from(nodes).filter((n) => {
817
+ if (n.nodeType === 8) return false; // the MSO conditionals themselves
818
+ if (n.nodeType === 3) return !!String(n.textContent || '').trim(); // layout whitespace between the divs
819
+ return n.nodeType === 1 && !/^(STYLE|SCRIPT)$/.test(n.tagName);
820
+ });
821
+ // Two to six siblings, all plain containers. A run of one is not a row, and
822
+ // a run of many is far more likely to be stacked content than columns.
823
+ if (kids.length < 2 || kids.length > 6) return null;
824
+ if (!kids.every((k) => k.nodeType === 1 && k.tagName === 'DIV')) return null;
825
+ // Side-by-side has to be stated, not guessed. `inline-block` is the email
826
+ // idiom; floats are the older one. A plain block div is stacked content and
827
+ // must not be swept up.
828
+ const sideBySide = (k) => {
829
+ const s = k.style || {};
830
+ return /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
831
+ };
832
+ if (!kids.every(sideBySide)) return null;
833
+ // Every sibling must declare a share of the row, and the shares must add up
834
+ // to one row. Anything else is a layout this cannot claim to understand.
835
+ const shareOf = (k) => {
836
+ const s = k.style || {};
837
+ const mw = s.maxWidth || '';
838
+ if (mw.endsWith('%')) return PX(mw);
839
+ const w = s.width || '';
840
+ if (w.endsWith('%') && PX(w) < 100) return PX(w);
841
+ // Nothing inline: the share may be carried by one of the element's
842
+ // classes (see classWidths).
843
+ const byClass = classWidths(k.ownerDocument);
844
+ const named = ((k.getAttribute && k.getAttribute('class')) || '').split(/\s+/);
845
+ for (let i = 0; i < named.length; i += 1) {
846
+ const v = byClass[named[i]];
847
+ if (v && v < 100) return v;
848
+ }
849
+ return 0;
850
+ };
851
+ const shares = kids.map(shareOf);
852
+ if (!shares.every((w) => w > 0)) return null;
853
+ const total = shares.reduce((a, b) => a + b, 0);
854
+ if (total < 90 || total > 110) return null;
855
+ kids.shares = shares;
856
+ return kids;
857
+ }
858
+
577
859
  /** Detects MailCraft's own two-table row shape -- an outer `<td>` (row padding/bg/border) wrapping a single-row `role="presentation"` table that holds the actual column(s), per `core/export.js` -- and widens to its cells (one or many) so rows round-trip back into real columns instead of one opaque `html` block. */
578
860
  function unwrapNestedLayout(td) {
579
861
  const only = onlyChild(td, 'TABLE');
580
862
  if (!only) return null;
863
+ // Not every single-row table is layout. A bulletproof button is a one-cell
864
+ // table too, and reading it as a row made its cell the row's `bgSource` --
865
+ // so a pink button in a white section repainted the whole band pink and
866
+ // the section's own background was lost. A component table is one block,
867
+ // never a row; the same guard covers a social strip built the same way.
868
+ if (classifyButton(td) || classifySocial(only)) return null;
581
869
  const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
582
870
  if (trs.length !== 1) return null;
583
871
  if (only.querySelector('th')) return null;
@@ -623,6 +911,39 @@ function padOf(el) {
623
911
  return el.style ? paddingOf(el.style) : null;
624
912
  }
625
913
 
914
+ /*
915
+ * One column of a row, on its own.
916
+ *
917
+ * The same container the multi-column detector groups, but appearing singly --
918
+ * a full-width MJML section is one of these. It matters because of what is
919
+ * *inside* it: a table whose every `<tr>` is one block. The generic walk turns
920
+ * every `<tr>` into a MailCraft row, so a section holding a heading and a
921
+ * subheading came back as two rows, each repainted with the section's
922
+ * background and each carrying the inner cell's padding while the section's
923
+ * own padding was dropped. A source `<tr>` inside a column is a block slot,
924
+ * not a row.
925
+ *
926
+ * Recognising the boundary keeps the column's blocks together in one row, and
927
+ * lets the section's `<td>` supply that row's padding, which is where it
928
+ * belonged all along. `tr -> row` is untouched everywhere else, which is what
929
+ * keeps MailCraft's own export round-tripping.
930
+ */
931
+ function isColumnContainer(el) {
932
+ if (!el || el.nodeType !== 1 || el.tagName !== 'DIV') return false;
933
+ const s = el.style || {};
934
+ const sideBySide = /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
935
+ if (!sideBySide) return false;
936
+ if (!(s.width || s.maxWidth)) return false;
937
+ // A column holds its blocks in a table; a lone inline-block div with no
938
+ // table is a badge or a pill, and is content in its own right.
939
+ return Array.from(el.children).some((c) => c.tagName === 'TABLE');
940
+ }
941
+
942
+ /** Every block a set of rows holds, in order -- a MailCraft column is a flat list. */
943
+ function blocksOfRows(rows) {
944
+ return rows.reduce((acc, r) => acc.concat(r.cols.reduce((a, c) => a.concat(c.blocks), [])), []);
945
+ }
946
+
626
947
  /** A div/center that itself nests a div/table/center is acting as a structural container (a section wrapper, an outer page wrapper holding several sections) rather than as one piece of content -- its children need to be walked in their own right, not folded into one block. A div holding only inline content (text, spans, an image not otherwise recognized) is real content and is left to `blocksFromNodes`. */
627
948
  function looksLikeContainer(el) {
628
949
  return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
@@ -735,8 +1056,14 @@ function rowsFromContentTable(table) {
735
1056
  const bgSource = outerCells[0];
736
1057
  let cells = outerCells;
737
1058
  if (outerCells.length === 1) {
1059
+ // The table shape first, since it is unambiguous; the CSS-layout shape
1060
+ // only when there is no table row to read.
738
1061
  const nested = unwrapNestedLayout(outerCells[0]);
739
1062
  if (nested) cells = nested;
1063
+ else {
1064
+ const inline = inlineColumnGroup(outerCells[0].childNodes);
1065
+ if (inline) cells = inline;
1066
+ }
740
1067
  }
741
1068
  // Spacer columns: a content-free `<td>` (often `class="column gap"`,
742
1069
  // holding only an empty fixed-width table) between real columns exists
@@ -792,7 +1119,10 @@ function rowsFromContentTable(table) {
792
1119
  // outer wrapper around all columns. When bgSource IS cells[0] (flat rows),
793
1120
  // falling back to it would re-promote the first card's color to the row.
794
1121
  const outerBg = cells.indexOf(bgSource) > -1 ? '' : bgOf(bgSource);
795
- const bg = (cellsUniform && cellBg) || outerBg || bgOf(table);
1122
+ // The tr between the cells and the table carries the legacy per-row
1123
+ // `bgcolor` old-school templates still set; it sits between the two in
1124
+ // specificity.
1125
+ const bg = (cellsUniform && cellBg) || bgOf(tr) || outerBg || bgOf(table);
796
1126
  if (bg) row.props.bg = bg;
797
1127
  // Differently-styled cells become per-column styling: each column keeps
798
1128
  // its own background/radius/padding (the pastel-cards pattern).
@@ -889,6 +1219,36 @@ function rowsFromContentTable(table) {
889
1219
  * its ancestor's.
890
1220
  */
891
1221
  function collectRows(nodes) {
1222
+ /*
1223
+ * Before anything else: are these siblings one row's columns rather than a
1224
+ * sequence of sections? This has to be asked here, of the whole list, and
1225
+ * not inside the loop below -- that loop's job is to walk each structural
1226
+ * container in its own right, which is exactly what flattened a real 50/50
1227
+ * row into two stacked full-width ones. The relationship between the
1228
+ * siblings is only visible while they are still siblings.
1229
+ */
1230
+ const columns = inlineColumnGroup(nodes);
1231
+ if (columns) {
1232
+ // The resolved shares win over the inline widths: a CSS-layout column is
1233
+ // `width:100%` inline, so reading the elements again would score every
1234
+ // column at 100 and fall back to an even split -- right for 33/33/34,
1235
+ // wrong for 40/60.
1236
+ const row = mkRow(columns.shares ? normalizeSpans(columns.shares) : spansFromCells(columns, 0));
1237
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1238
+ columns.forEach((k, i) => {
1239
+ // A MailCraft column holds a flat list of blocks, so whatever the
1240
+ // column div decomposes into is flattened into it. Recursing through
1241
+ // collectRows rather than blocksFromNodes is what lets a column hold a
1242
+ // content table (the usual MJML shape) instead of one opaque blob.
1243
+ row.cols[i].blocks = blocksOfRows(collectRows(Array.from(k.childNodes)));
1244
+ const cbg = bgOf(k); if (cbg) row.cols[i].bg = cbg;
1245
+ const crad = radiusOf(k.style); if (crad) row.cols[i].radius = crad;
1246
+ const cpd = padOf(k); if (cpd) { row.cols[i].padY = cpd.py; row.cols[i].padX = cpd.px; }
1247
+ });
1248
+ // If nothing recognisable came out of it, it was not a row after all --
1249
+ // fall through and let the ordinary walk have it.
1250
+ if (row.cols.some((c) => c.blocks.length)) return [row];
1251
+ }
892
1252
  const rows = [];
893
1253
  let buf = [];
894
1254
  const flushBuf = () => {
@@ -905,9 +1265,21 @@ function collectRows(nodes) {
905
1265
  if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
906
1266
  if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) return;
907
1267
  if (n.nodeType === 1 && isHidden(n)) return;
1268
+ // A lone column: its blocks belong to one row, not one row each.
1269
+ if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
1270
+ flushBuf();
1271
+ const blocks = blocksOfRows(collectRows(Array.from(n.childNodes)));
1272
+ if (blocks.length) {
1273
+ const row = mkRow([100]);
1274
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1275
+ row.cols[0].blocks = blocks;
1276
+ rows.push(...applyBgImage(applyFrame(applyPad(applyBg([row], bgOf(n)), padOf(n)), n), n));
1277
+ return;
1278
+ }
1279
+ }
908
1280
  if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
909
1281
  flushBuf();
910
- const inner = collectRows(Array.from(n.childNodes));
1282
+ const inner = mergeBandRows(collectRows(Array.from(n.childNodes)), n);
911
1283
  rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
912
1284
  return;
913
1285
  }
@@ -948,11 +1320,37 @@ function collectRows(nodes) {
948
1320
  rows.push(row);
949
1321
  return;
950
1322
  }
951
- const inner = collectRows(Array.from(td.childNodes));
1323
+ // The other table-email rule idiom: a content-free cell whose only
1324
+ // drawing is a `border-top` (Beefree's `td.divider_inner`, usually in
1325
+ // a width="20%" inner table). As a text run it imported as a junk row
1326
+ // holding one hair space; recognized, it is the divider it draws.
1327
+ const dSides = borderSidesOf(td.style);
1328
+ if (dSides.sides.top > 0 && dSides.sides.top <= 10
1329
+ && !dSides.sides.right && !dSides.sides.bottom && !dSides.sides.left
1330
+ && !bgOf(td) && !bgImageOf(td)
1331
+ && !Array.from(td.children).some((c) => c.tagName !== 'SPAN' && c.tagName !== 'BR')
1332
+ && !(td.textContent || '').replace(/\s/g, '')) {
1333
+ const dw = String(n.getAttribute('width') || n.style.width || '');
1334
+ const row = mkRow([100]);
1335
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1336
+ row.cols[0].blocks = [blk('divider', {
1337
+ thickness: dSides.sides.top,
1338
+ lineStyle: borderStyleOf(td.style),
1339
+ color: borderColorOf(td.style) || '#d9dade',
1340
+ width: dw.endsWith('%') ? PX(dw) : 100,
1341
+ })];
1342
+ rows.push(row);
1343
+ return;
1344
+ }
1345
+ const inner = mergeBandRows(mergeBandRows(collectRows(Array.from(td.childNodes)), td), n);
952
1346
  // Frame styles live on the td for some builders and on the table
953
1347
  // itself for others (`table.row-content` carries the card border) --
954
1348
  // applyFrame fills only what's still unset, so trying both is safe.
955
- rows.push(...applyBgImage(applyBgImage(applyFrame(applyFrame(applyPad(applyBg(inner, bgOf(n) || bgOf(td)), padOf(td) || padOf(n) || cellPadOf(n)), td), n), td), n));
1349
+ // The td is the more specific background-color source -- a dark hero
1350
+ // cell inside a white content table keeps its own color, not the
1351
+ // wrapper's (the image already reads td-first via the applyBgImage
1352
+ // order). The tr between them carries the legacy per-row bgcolor.
1353
+ rows.push(...applyBgImage(applyBgImage(applyFrame(applyFrame(applyPad(applyBg(inner, bgOf(td) || bgOf(tr) || bgOf(n)), padOf(td) || padOf(n) || cellPadOf(n)), td), n), td), n));
956
1354
  return;
957
1355
  }
958
1356
  flushBuf();
@@ -974,6 +1372,36 @@ function collectRows(nodes) {
974
1372
  * document's theme -- a DM Sans email on #F1F5F9 came back in the default
975
1373
  * Georgia on the default parchment, which read as "the import broke".
976
1374
  */
1375
+ /**
1376
+ * A layout table's committed pixel width, however it declares one: the
1377
+ * `width` attribute, a `width` style, or -- for a responsive template -- the
1378
+ * `max-width` that caps a fluid `width:100%`.
1379
+ *
1380
+ * `max-width` is not a nicety. It is how this exporter now writes the content
1381
+ * column (`width:100%;max-width:620px`, so the email can narrow to a phone),
1382
+ * and it is the shape every other modern email builder emits too. Reading
1383
+ * only the fixed forms meant an export -> import round trip came back with no
1384
+ * `theme.width` at all and silently fell to the default. Returns 0 for a
1385
+ * purely proportional table, which the callers already skip.
1386
+ */
1387
+ function fixedWidthOf(tb) {
1388
+ const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
1389
+ if (w && !String(w).endsWith('%')) return PX(w);
1390
+ const cap = tb.style && tb.style.maxWidth;
1391
+ if (cap && !String(cap).endsWith('%')) return PX(cap);
1392
+ return 0;
1393
+ }
1394
+
1395
+ /** A border present on some sides but not all, or a radius rounding some corners but not all -- the signature of one piece of a card that was drawn across several stacked tables, never of a standalone card. */
1396
+ function partialFrame(st) {
1397
+ if (!st) return false;
1398
+ const { sides } = borderSidesOf(st);
1399
+ const on = ['top', 'right', 'bottom', 'left'].filter((k) => sides[k] > 0).length;
1400
+ const corners = String(st.borderRadius || '').split(/\s+/).map(PX);
1401
+ const uneven = corners.length > 1 && corners.some((v) => v > 0) && corners.some((v) => !v);
1402
+ return (on > 0 && on < 4) || uneven;
1403
+ }
1404
+
977
1405
  function themeFromParsedDoc(doc) {
978
1406
  const theme = {};
979
1407
  const body = doc.body;
@@ -985,9 +1413,7 @@ function themeFromParsedDoc(doc) {
985
1413
  if (bg) theme.bg = bg;
986
1414
  const widthCounts = {};
987
1415
  body.querySelectorAll('table').forEach((tb) => {
988
- const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
989
- if (!w || String(w).endsWith('%')) return;
990
- const px = PX(w);
1416
+ const px = fixedWidthOf(tb);
991
1417
  if (px >= 320 && px <= 900) widthCounts[px] = (widthCounts[px] || 0) + 1;
992
1418
  });
993
1419
  const bestWidth = Object.keys(widthCounts).sort((a, b) => widthCounts[b] - widthCounts[a])[0];
@@ -998,11 +1424,12 @@ function themeFromParsedDoc(doc) {
998
1424
  // column's corner. Without this an import flattened both, and the next
999
1425
  // export silently squared the template off and closed the gap around it.
1000
1426
  if (bestWidth) {
1001
- const content = Array.from(body.querySelectorAll('table')).find((tb) => {
1002
- const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
1003
- return w && !String(w).endsWith('%') && PX(w) === Number(bestWidth);
1004
- });
1427
+ const content = Array.from(body.querySelectorAll('table')).find((tb) => fixedWidthOf(tb) === Number(bestWidth));
1005
1428
  if (content) {
1429
+ // Read before the frame is consumed below -- a fragment here means the
1430
+ // sibling tables hold the rest of the same card (see the pass further
1431
+ // down).
1432
+ const contentPartial = partialFrame(content.style);
1006
1433
  // The content column's own background -- including the literal
1007
1434
  // `transparent` the exporter always writes for a see-through column.
1008
1435
  // Without this the round trip lost it: export wrote
@@ -1044,6 +1471,30 @@ function themeFromParsedDoc(doc) {
1044
1471
  if (padX > 0) theme.padX = padX;
1045
1472
  cell.style.padding = '';
1046
1473
  }
1474
+ // A card drawn across several sibling content-width tables (top piece:
1475
+ // `border-radius:16px 16px 0 0; border-bottom:none`, middle pieces:
1476
+ // side borders only, bottom piece: `border-radius:0 0 16px 16px`) is
1477
+ // ONE visual frame -- the one just claimed. Left in place, the sibling
1478
+ // fragments imported as row borders inside the already-framed canvas:
1479
+ // doubled verticals down every row and a second rounded, shadowed box
1480
+ // around the last section. Only the split-card idiom trips this: the
1481
+ // claimed table must itself be a fragment (partial border or uneven
1482
+ // corners), and only fragments are stripped -- a template of genuinely
1483
+ // separate full cards has neither and is untouched. Horizontal edges
1484
+ // stay, since between two sections they read as a real separator; the
1485
+ // last fragment's bottom edge coincides with the frame's and goes.
1486
+ if (contentPartial) {
1487
+ const same = Array.from(body.querySelectorAll('table'))
1488
+ .filter((tb) => tb !== content && tb.style && fixedWidthOf(tb) === Number(bestWidth));
1489
+ same.forEach((tb, k) => {
1490
+ if (!partialFrame(tb.style)) return;
1491
+ tb.style.borderLeft = '';
1492
+ tb.style.borderRight = '';
1493
+ tb.style.borderRadius = '';
1494
+ tb.style.boxShadow = '';
1495
+ if (k === same.length - 1) tb.style.borderBottom = '';
1496
+ });
1497
+ }
1047
1498
  }
1048
1499
  }
1049
1500
  // Prefer the first *real* stack (has a comma or quotes) over a lone generic