@seliseblocks/mailcraft 0.2.7 → 0.2.8
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/mailcraft-editor.bundle.js +74 -48
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +1 -1
- package/src/core/blocks.js +15 -1
- package/src/core/editor-core.js +31 -1
- package/src/core/export.js +195 -6
- package/src/core/import-html.js +330 -20
- package/src/core/sanitize.js +8 -1
- package/src/render/block-body.js +109 -12
- package/src/render/canvas.js +45 -3
package/src/core/import-html.js
CHANGED
|
@@ -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;
|
|
@@ -209,10 +235,25 @@ function classifyImage(el) {
|
|
|
209
235
|
}
|
|
210
236
|
|
|
211
237
|
function classifyButton(el) {
|
|
212
|
-
let a = null; let outerAlign = '';
|
|
238
|
+
let a = null; let outerAlign = ''; let pillTable = null;
|
|
213
239
|
if (el.tagName === 'A') a = el;
|
|
214
240
|
else if (el.tagName === 'DIV' || el.tagName === 'TD') {
|
|
215
241
|
a = onlyChild(el, 'A');
|
|
242
|
+
// The bulletproof shape: the anchor is wrapped in a one-cell table so
|
|
243
|
+
// Word has a `<td>` to paint and pad (this exporter emits that, and so
|
|
244
|
+
// does every hand-written bulletproof button). Reached through the
|
|
245
|
+
// wrapper rather than the cell because that is the node the row walker
|
|
246
|
+
// offers, and the wrapper is also what carries the alignment.
|
|
247
|
+
if (!a) {
|
|
248
|
+
pillTable = onlyChild(el, 'TABLE');
|
|
249
|
+
// Direct row cells only. A descendant search would count the `<td>`s
|
|
250
|
+
// inside whatever the cell contains, so a layout row holding one block
|
|
251
|
+
// could look like a one-cell button table -- or stop looking like one
|
|
252
|
+
// the moment the block itself contained a table.
|
|
253
|
+
const cells = pillTable ? pillTable.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td') : [];
|
|
254
|
+
if (cells.length === 1) a = onlyChild(cells[0], 'A');
|
|
255
|
+
if (!a) pillTable = null;
|
|
256
|
+
}
|
|
216
257
|
if (a) outerAlign = el.style.textAlign || el.getAttribute('align') || '';
|
|
217
258
|
}
|
|
218
259
|
if (!a) return null;
|
|
@@ -221,11 +262,16 @@ function classifyButton(el) {
|
|
|
221
262
|
// generators (Beefree et al.) leave the `<a>` bare and hang the background,
|
|
222
263
|
// radius and padding on nested `<span>`s inside it. Whichever element
|
|
223
264
|
// 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
|
-
|
|
265
|
+
const hasBg = (e) => !!(e.style && (e.style.backgroundColor || e.style.background)) || !!(e.getAttribute && e.getAttribute('bgcolor'));
|
|
266
|
+
// The cell is checked last and only as a fallback: an anchor that paints
|
|
267
|
+
// its own pill still describes the button best (that is where the radius
|
|
268
|
+
// and the label colour sit), and the cell is what carries them when the
|
|
269
|
+
// source put the paint and the padding on the `<td>` instead.
|
|
270
|
+
const cell = a.closest ? a.closest('td') : null;
|
|
271
|
+
const pill = hasBg(a) ? a : (Array.from(a.querySelectorAll('span')).find(hasBg) || (cell && hasBg(cell) ? cell : null));
|
|
226
272
|
if (!pill) return null;
|
|
227
273
|
const st = pill.style;
|
|
228
|
-
let pad = paddingOf(a.style) || paddingOf(st);
|
|
274
|
+
let pad = paddingOf(a.style) || paddingOf(st) || (cell ? paddingOf(cell.style) : null);
|
|
229
275
|
if (!pad) {
|
|
230
276
|
const padded = Array.from(pill.querySelectorAll('span')).find((s) => paddingOf(s.style));
|
|
231
277
|
if (padded) pad = paddingOf(padded.style);
|
|
@@ -235,8 +281,8 @@ function classifyButton(el) {
|
|
|
235
281
|
const over = {
|
|
236
282
|
label: (a.textContent || '').trim() || 'Button',
|
|
237
283
|
href: a.getAttribute('href') || '#',
|
|
238
|
-
bg: st.backgroundColor || st.background,
|
|
239
|
-
color:
|
|
284
|
+
bg: st.backgroundColor || st.background || (pill.getAttribute && pill.getAttribute('bgcolor')) || '',
|
|
285
|
+
color: a.style.color || st.color || '#ffffff',
|
|
240
286
|
radius: radiusOf(st),
|
|
241
287
|
py: (pad && pad.py) || 13,
|
|
242
288
|
px: (pad && pad.px) || 26,
|
|
@@ -245,13 +291,23 @@ function classifyButton(el) {
|
|
|
245
291
|
// about where the pill sits in the row -- that's the container's call,
|
|
246
292
|
// so alignment is read starting at the parent, never at the anchor.
|
|
247
293
|
align: outerAlign || textAlignOf(a.parentElement || a),
|
|
248
|
-
|
|
294
|
+
// In the one-cell shape the anchor is always `display:block` (it fills
|
|
295
|
+
// the padded cell), so full-width has to be read off the table instead --
|
|
296
|
+
// otherwise every bulletproof button imports as a full-width one.
|
|
297
|
+
full: pillTable
|
|
298
|
+
? /100%/.test((pillTable.style && pillTable.style.width) || pillTable.getAttribute('width') || '')
|
|
299
|
+
: a.style.display === 'block',
|
|
249
300
|
};
|
|
250
301
|
const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
|
|
251
302
|
if (size) over.size = size;
|
|
252
|
-
// Outline buttons: transparent fill, the pill drawn by its border.
|
|
253
|
-
|
|
254
|
-
|
|
303
|
+
// Outline buttons: transparent fill, the pill drawn by its border. The
|
|
304
|
+
// border is looked for on the cell as well as the pill, because in the
|
|
305
|
+
// one-cell shape the paint and the frame are on different elements -- the
|
|
306
|
+
// anchor is the pill (it carries the background) while the `<td>` draws the
|
|
307
|
+
// outline. Reading only the pill dropped `borderW` on every round trip.
|
|
308
|
+
const frame = borderSidesOf(st).width ? st : (cell && cell.style && borderSidesOf(cell.style).width ? cell.style : st);
|
|
309
|
+
const bw = borderSidesOf(frame).width;
|
|
310
|
+
if (bw) { over.borderW = bw; over.borderStyle = borderStyleOf(frame); over.borderColor = borderColorOf(frame) || over.color; }
|
|
255
311
|
return blk('button', over);
|
|
256
312
|
}
|
|
257
313
|
|
|
@@ -397,7 +453,18 @@ const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu
|
|
|
397
453
|
function classifyNode(el) {
|
|
398
454
|
for (const fn of CLASSIFIERS) {
|
|
399
455
|
const b = fn(el);
|
|
400
|
-
if (b)
|
|
456
|
+
if (!b) continue;
|
|
457
|
+
// The class sits on the wrapper the exporter puts around each block, and
|
|
458
|
+
// on a foreign template it can be a level or two further out, so a short
|
|
459
|
+
// walk up finds it. Only ever set when a wrapper actually says so --
|
|
460
|
+
// absent means "all devices", which is what every ordinary block wants.
|
|
461
|
+
let n = el;
|
|
462
|
+
for (let i = 0; n && i < 4; i += 1) {
|
|
463
|
+
const v = visibilityOf(n);
|
|
464
|
+
if (v) { b.props.vis = v; break; }
|
|
465
|
+
n = n.parentElement;
|
|
466
|
+
}
|
|
467
|
+
return b;
|
|
401
468
|
}
|
|
402
469
|
return null;
|
|
403
470
|
}
|
|
@@ -409,7 +476,18 @@ function isStructural(el) {
|
|
|
409
476
|
/** `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
477
|
function unwrapBoxDiv(el) {
|
|
411
478
|
if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
|
|
412
|
-
|
|
479
|
+
// A device-visibility wrapper is framework too, and the declarations that
|
|
480
|
+
// hide it are the framework's, not the author's: a mobile-only block is
|
|
481
|
+
// `display:none` in the base stylesheet so Classic Outlook never shows it,
|
|
482
|
+
// and css-cascade folds that inline on import. Without this exemption the
|
|
483
|
+
// wrapper looked like a deliberately styled container, never unwrapped, and
|
|
484
|
+
// the heading inside it came back as an untyped run of text. `classifyNode`
|
|
485
|
+
// reads the visibility off this same wrapper by walking back up, so the
|
|
486
|
+
// property survives the unwrap.
|
|
487
|
+
const framework = visibilityOf(el)
|
|
488
|
+
? /^(?:margin|display|max-height|overflow|mso-)/
|
|
489
|
+
: /^margin/;
|
|
490
|
+
const extra = Array.from(el.style).filter((prop) => !framework.test(prop));
|
|
413
491
|
if (extra.length) return el;
|
|
414
492
|
const child = el.firstElementChild;
|
|
415
493
|
if ((el.textContent || '') !== (child.textContent || '')) return el;
|
|
@@ -557,10 +635,16 @@ function normalizeSpans(spans) {
|
|
|
557
635
|
function spansFromCells(cells, tableWidthPx) {
|
|
558
636
|
const parsed = cells.map((td) => {
|
|
559
637
|
const sw = td.style.width || '';
|
|
638
|
+
// `max-width` before `width`, but only where `width` says nothing useful.
|
|
639
|
+
// A CSS-layout column is written `width:100%;max-width:50%` -- the 100%
|
|
640
|
+
// is the fluid instruction and the cap is the actual share of the row, so
|
|
641
|
+
// reading `width` first would score every column at 100.
|
|
642
|
+
const mw = td.style.maxWidth || '';
|
|
643
|
+
if (mw.endsWith('%') && (!sw || PX(sw) >= 100)) return { pct: PX(mw) };
|
|
560
644
|
if (sw.endsWith('%')) return { pct: PX(sw) };
|
|
561
645
|
const aw = td.getAttribute('width') || '';
|
|
562
646
|
if (aw.endsWith('%')) return { pct: PX(aw) };
|
|
563
|
-
const px = PX(sw) || PX(aw);
|
|
647
|
+
const px = PX(sw) || PX(aw) || (mw.endsWith('px') ? PX(mw) : 0);
|
|
564
648
|
return px ? { px } : null;
|
|
565
649
|
});
|
|
566
650
|
if (parsed.every((p) => p && p.pct != null)) {
|
|
@@ -574,10 +658,140 @@ function spansFromCells(cells, tableWidthPx) {
|
|
|
574
658
|
return spans;
|
|
575
659
|
}
|
|
576
660
|
|
|
661
|
+
/*
|
|
662
|
+
* Columns that are not table cells.
|
|
663
|
+
*
|
|
664
|
+
* A row's columns do not have to be `<td>`s. MJML, and to a degree Unlayer and
|
|
665
|
+
* Stripo, emit them as sibling `<div>`s that sit side by side through
|
|
666
|
+
* `display:inline-block` and a percentage cap, with the table version hidden
|
|
667
|
+
* inside `<!--[if mso]>` conditionals for Outlook. Those comments are comments
|
|
668
|
+
* to a DOM parser, so all the walker ever saw was one `<td>` holding two
|
|
669
|
+
* divs -- and a real 50/50 row imported as two stacked full-width rows. The
|
|
670
|
+
* blocks all survived; the layout relationship did not.
|
|
671
|
+
*
|
|
672
|
+
* Returning the divs here hands them to exactly the same machinery the table
|
|
673
|
+
* path already uses -- `spansFromCells` for the widths, gap and gutter
|
|
674
|
+
* detection, per-column background and padding -- so there is no second
|
|
675
|
+
* column model, only a second way of recognising one.
|
|
676
|
+
*
|
|
677
|
+
* Deliberately conservative, because a wrong grouping is worse than none: a
|
|
678
|
+
* false positive welds unrelated sections into one row, which the user then
|
|
679
|
+
* has to take apart by hand. Every one of these has to hold.
|
|
680
|
+
*/
|
|
681
|
+
/*
|
|
682
|
+
* A column's share of its row is not always in its inline style.
|
|
683
|
+
*
|
|
684
|
+
* MJML writes the column as `width:100%` inline and puts the real share in a
|
|
685
|
+
* class rule inside `@media only screen and (min-width:480px)` -- a *desktop*
|
|
686
|
+
* query, so it carries the layout rather than overriding it. `css-cascade.js`
|
|
687
|
+
* strips every at-rule before folding, by design (a mobile override cannot be
|
|
688
|
+
* represented in the model), which means that share never reaches the element
|
|
689
|
+
* and three columns looked like three widthless divs.
|
|
690
|
+
*
|
|
691
|
+
* So the stylesheets are read once per document for `.class { width: N% }`,
|
|
692
|
+
* from top-level rules and from `min-width` blocks -- never from `max-width`
|
|
693
|
+
* blocks, which are the mobile overrides the cascade is right to drop.
|
|
694
|
+
*/
|
|
695
|
+
const CLASS_WIDTHS = new WeakMap();
|
|
696
|
+
|
|
697
|
+
function scanWidthRules(css, map) {
|
|
698
|
+
let i = 0;
|
|
699
|
+
while (i < css.length) {
|
|
700
|
+
const at = css.indexOf('@', i);
|
|
701
|
+
const plain = css.slice(i, at < 0 ? css.length : at);
|
|
702
|
+
plain.replace(/([^{}]+)\{([^{}]*)\}/g, (m0, sel, decl) => {
|
|
703
|
+
const w = decl.match(/(?:^|;)\s*width\s*:\s*([\d.]+)%/i);
|
|
704
|
+
if (w) {
|
|
705
|
+
sel.split(',').forEach((one) => {
|
|
706
|
+
const cm = one.trim().match(/^\.([-\w]+)$/);
|
|
707
|
+
if (cm && !map[cm[1]]) map[cm[1]] = parseFloat(w[1]);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
return m0;
|
|
711
|
+
});
|
|
712
|
+
if (at < 0) break;
|
|
713
|
+
const brace = css.indexOf('{', at);
|
|
714
|
+
if (brace < 0) break;
|
|
715
|
+
const prelude = css.slice(at, brace);
|
|
716
|
+
let depth = 0;
|
|
717
|
+
let j = brace;
|
|
718
|
+
for (; j < css.length; j += 1) {
|
|
719
|
+
if (css[j] === '{') depth += 1;
|
|
720
|
+
else if (css[j] === '}') { depth -= 1; if (!depth) break; }
|
|
721
|
+
}
|
|
722
|
+
// `max-width` is a narrow-screen override, which says nothing about the
|
|
723
|
+
// desktop layout this is trying to recover.
|
|
724
|
+
if (!/max-width/i.test(prelude)) scanWidthRules(css.slice(brace + 1, j), map);
|
|
725
|
+
i = j + 1;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function classWidths(doc) {
|
|
730
|
+
if (!doc || !doc.querySelectorAll) return {};
|
|
731
|
+
const hit = CLASS_WIDTHS.get(doc);
|
|
732
|
+
if (hit) return hit;
|
|
733
|
+
const map = {};
|
|
734
|
+
Array.from(doc.querySelectorAll('style')).forEach((st) => {
|
|
735
|
+
scanWidthRules(String(st.textContent || '').replace(/\/\*[\s\S]*?\*\//g, ''), map);
|
|
736
|
+
});
|
|
737
|
+
CLASS_WIDTHS.set(doc, map);
|
|
738
|
+
return map;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function inlineColumnGroup(nodes) {
|
|
742
|
+
const kids = Array.from(nodes).filter((n) => {
|
|
743
|
+
if (n.nodeType === 8) return false; // the MSO conditionals themselves
|
|
744
|
+
if (n.nodeType === 3) return !!String(n.textContent || '').trim(); // layout whitespace between the divs
|
|
745
|
+
return n.nodeType === 1 && !/^(STYLE|SCRIPT)$/.test(n.tagName);
|
|
746
|
+
});
|
|
747
|
+
// Two to six siblings, all plain containers. A run of one is not a row, and
|
|
748
|
+
// a run of many is far more likely to be stacked content than columns.
|
|
749
|
+
if (kids.length < 2 || kids.length > 6) return null;
|
|
750
|
+
if (!kids.every((k) => k.nodeType === 1 && k.tagName === 'DIV')) return null;
|
|
751
|
+
// Side-by-side has to be stated, not guessed. `inline-block` is the email
|
|
752
|
+
// idiom; floats are the older one. A plain block div is stacked content and
|
|
753
|
+
// must not be swept up.
|
|
754
|
+
const sideBySide = (k) => {
|
|
755
|
+
const s = k.style || {};
|
|
756
|
+
return /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
|
|
757
|
+
};
|
|
758
|
+
if (!kids.every(sideBySide)) return null;
|
|
759
|
+
// Every sibling must declare a share of the row, and the shares must add up
|
|
760
|
+
// to one row. Anything else is a layout this cannot claim to understand.
|
|
761
|
+
const shareOf = (k) => {
|
|
762
|
+
const s = k.style || {};
|
|
763
|
+
const mw = s.maxWidth || '';
|
|
764
|
+
if (mw.endsWith('%')) return PX(mw);
|
|
765
|
+
const w = s.width || '';
|
|
766
|
+
if (w.endsWith('%') && PX(w) < 100) return PX(w);
|
|
767
|
+
// Nothing inline: the share may be carried by one of the element's
|
|
768
|
+
// classes (see classWidths).
|
|
769
|
+
const byClass = classWidths(k.ownerDocument);
|
|
770
|
+
const named = ((k.getAttribute && k.getAttribute('class')) || '').split(/\s+/);
|
|
771
|
+
for (let i = 0; i < named.length; i += 1) {
|
|
772
|
+
const v = byClass[named[i]];
|
|
773
|
+
if (v && v < 100) return v;
|
|
774
|
+
}
|
|
775
|
+
return 0;
|
|
776
|
+
};
|
|
777
|
+
const shares = kids.map(shareOf);
|
|
778
|
+
if (!shares.every((w) => w > 0)) return null;
|
|
779
|
+
const total = shares.reduce((a, b) => a + b, 0);
|
|
780
|
+
if (total < 90 || total > 110) return null;
|
|
781
|
+
kids.shares = shares;
|
|
782
|
+
return kids;
|
|
783
|
+
}
|
|
784
|
+
|
|
577
785
|
/** 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
786
|
function unwrapNestedLayout(td) {
|
|
579
787
|
const only = onlyChild(td, 'TABLE');
|
|
580
788
|
if (!only) return null;
|
|
789
|
+
// Not every single-row table is layout. A bulletproof button is a one-cell
|
|
790
|
+
// table too, and reading it as a row made its cell the row's `bgSource` --
|
|
791
|
+
// so a pink button in a white section repainted the whole band pink and
|
|
792
|
+
// the section's own background was lost. A component table is one block,
|
|
793
|
+
// never a row; the same guard covers a social strip built the same way.
|
|
794
|
+
if (classifyButton(td) || classifySocial(only)) return null;
|
|
581
795
|
const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
|
|
582
796
|
if (trs.length !== 1) return null;
|
|
583
797
|
if (only.querySelector('th')) return null;
|
|
@@ -623,6 +837,39 @@ function padOf(el) {
|
|
|
623
837
|
return el.style ? paddingOf(el.style) : null;
|
|
624
838
|
}
|
|
625
839
|
|
|
840
|
+
/*
|
|
841
|
+
* One column of a row, on its own.
|
|
842
|
+
*
|
|
843
|
+
* The same container the multi-column detector groups, but appearing singly --
|
|
844
|
+
* a full-width MJML section is one of these. It matters because of what is
|
|
845
|
+
* *inside* it: a table whose every `<tr>` is one block. The generic walk turns
|
|
846
|
+
* every `<tr>` into a MailCraft row, so a section holding a heading and a
|
|
847
|
+
* subheading came back as two rows, each repainted with the section's
|
|
848
|
+
* background and each carrying the inner cell's padding while the section's
|
|
849
|
+
* own padding was dropped. A source `<tr>` inside a column is a block slot,
|
|
850
|
+
* not a row.
|
|
851
|
+
*
|
|
852
|
+
* Recognising the boundary keeps the column's blocks together in one row, and
|
|
853
|
+
* lets the section's `<td>` supply that row's padding, which is where it
|
|
854
|
+
* belonged all along. `tr -> row` is untouched everywhere else, which is what
|
|
855
|
+
* keeps MailCraft's own export round-tripping.
|
|
856
|
+
*/
|
|
857
|
+
function isColumnContainer(el) {
|
|
858
|
+
if (!el || el.nodeType !== 1 || el.tagName !== 'DIV') return false;
|
|
859
|
+
const s = el.style || {};
|
|
860
|
+
const sideBySide = /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
|
|
861
|
+
if (!sideBySide) return false;
|
|
862
|
+
if (!(s.width || s.maxWidth)) return false;
|
|
863
|
+
// A column holds its blocks in a table; a lone inline-block div with no
|
|
864
|
+
// table is a badge or a pill, and is content in its own right.
|
|
865
|
+
return Array.from(el.children).some((c) => c.tagName === 'TABLE');
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Every block a set of rows holds, in order -- a MailCraft column is a flat list. */
|
|
869
|
+
function blocksOfRows(rows) {
|
|
870
|
+
return rows.reduce((acc, r) => acc.concat(r.cols.reduce((a, c) => a.concat(c.blocks), [])), []);
|
|
871
|
+
}
|
|
872
|
+
|
|
626
873
|
/** 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
874
|
function looksLikeContainer(el) {
|
|
628
875
|
return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
|
|
@@ -735,8 +982,14 @@ function rowsFromContentTable(table) {
|
|
|
735
982
|
const bgSource = outerCells[0];
|
|
736
983
|
let cells = outerCells;
|
|
737
984
|
if (outerCells.length === 1) {
|
|
985
|
+
// The table shape first, since it is unambiguous; the CSS-layout shape
|
|
986
|
+
// only when there is no table row to read.
|
|
738
987
|
const nested = unwrapNestedLayout(outerCells[0]);
|
|
739
988
|
if (nested) cells = nested;
|
|
989
|
+
else {
|
|
990
|
+
const inline = inlineColumnGroup(outerCells[0].childNodes);
|
|
991
|
+
if (inline) cells = inline;
|
|
992
|
+
}
|
|
740
993
|
}
|
|
741
994
|
// Spacer columns: a content-free `<td>` (often `class="column gap"`,
|
|
742
995
|
// holding only an empty fixed-width table) between real columns exists
|
|
@@ -889,6 +1142,36 @@ function rowsFromContentTable(table) {
|
|
|
889
1142
|
* its ancestor's.
|
|
890
1143
|
*/
|
|
891
1144
|
function collectRows(nodes) {
|
|
1145
|
+
/*
|
|
1146
|
+
* Before anything else: are these siblings one row's columns rather than a
|
|
1147
|
+
* sequence of sections? This has to be asked here, of the whole list, and
|
|
1148
|
+
* not inside the loop below -- that loop's job is to walk each structural
|
|
1149
|
+
* container in its own right, which is exactly what flattened a real 50/50
|
|
1150
|
+
* row into two stacked full-width ones. The relationship between the
|
|
1151
|
+
* siblings is only visible while they are still siblings.
|
|
1152
|
+
*/
|
|
1153
|
+
const columns = inlineColumnGroup(nodes);
|
|
1154
|
+
if (columns) {
|
|
1155
|
+
// The resolved shares win over the inline widths: a CSS-layout column is
|
|
1156
|
+
// `width:100%` inline, so reading the elements again would score every
|
|
1157
|
+
// column at 100 and fall back to an even split -- right for 33/33/34,
|
|
1158
|
+
// wrong for 40/60.
|
|
1159
|
+
const row = mkRow(columns.shares ? normalizeSpans(columns.shares) : spansFromCells(columns, 0));
|
|
1160
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1161
|
+
columns.forEach((k, i) => {
|
|
1162
|
+
// A MailCraft column holds a flat list of blocks, so whatever the
|
|
1163
|
+
// column div decomposes into is flattened into it. Recursing through
|
|
1164
|
+
// collectRows rather than blocksFromNodes is what lets a column hold a
|
|
1165
|
+
// content table (the usual MJML shape) instead of one opaque blob.
|
|
1166
|
+
row.cols[i].blocks = blocksOfRows(collectRows(Array.from(k.childNodes)));
|
|
1167
|
+
const cbg = bgOf(k); if (cbg) row.cols[i].bg = cbg;
|
|
1168
|
+
const crad = radiusOf(k.style); if (crad) row.cols[i].radius = crad;
|
|
1169
|
+
const cpd = padOf(k); if (cpd) { row.cols[i].padY = cpd.py; row.cols[i].padX = cpd.px; }
|
|
1170
|
+
});
|
|
1171
|
+
// If nothing recognisable came out of it, it was not a row after all --
|
|
1172
|
+
// fall through and let the ordinary walk have it.
|
|
1173
|
+
if (row.cols.some((c) => c.blocks.length)) return [row];
|
|
1174
|
+
}
|
|
892
1175
|
const rows = [];
|
|
893
1176
|
let buf = [];
|
|
894
1177
|
const flushBuf = () => {
|
|
@@ -905,6 +1188,18 @@ function collectRows(nodes) {
|
|
|
905
1188
|
if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
|
|
906
1189
|
if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) return;
|
|
907
1190
|
if (n.nodeType === 1 && isHidden(n)) return;
|
|
1191
|
+
// A lone column: its blocks belong to one row, not one row each.
|
|
1192
|
+
if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
|
|
1193
|
+
flushBuf();
|
|
1194
|
+
const blocks = blocksOfRows(collectRows(Array.from(n.childNodes)));
|
|
1195
|
+
if (blocks.length) {
|
|
1196
|
+
const row = mkRow([100]);
|
|
1197
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1198
|
+
row.cols[0].blocks = blocks;
|
|
1199
|
+
rows.push(...applyBgImage(applyFrame(applyPad(applyBg([row], bgOf(n)), padOf(n)), n), n));
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
908
1203
|
if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
|
|
909
1204
|
flushBuf();
|
|
910
1205
|
const inner = collectRows(Array.from(n.childNodes));
|
|
@@ -974,6 +1269,26 @@ function collectRows(nodes) {
|
|
|
974
1269
|
* document's theme -- a DM Sans email on #F1F5F9 came back in the default
|
|
975
1270
|
* Georgia on the default parchment, which read as "the import broke".
|
|
976
1271
|
*/
|
|
1272
|
+
/**
|
|
1273
|
+
* A layout table's committed pixel width, however it declares one: the
|
|
1274
|
+
* `width` attribute, a `width` style, or -- for a responsive template -- the
|
|
1275
|
+
* `max-width` that caps a fluid `width:100%`.
|
|
1276
|
+
*
|
|
1277
|
+
* `max-width` is not a nicety. It is how this exporter now writes the content
|
|
1278
|
+
* column (`width:100%;max-width:620px`, so the email can narrow to a phone),
|
|
1279
|
+
* and it is the shape every other modern email builder emits too. Reading
|
|
1280
|
+
* only the fixed forms meant an export -> import round trip came back with no
|
|
1281
|
+
* `theme.width` at all and silently fell to the default. Returns 0 for a
|
|
1282
|
+
* purely proportional table, which the callers already skip.
|
|
1283
|
+
*/
|
|
1284
|
+
function fixedWidthOf(tb) {
|
|
1285
|
+
const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
|
|
1286
|
+
if (w && !String(w).endsWith('%')) return PX(w);
|
|
1287
|
+
const cap = tb.style && tb.style.maxWidth;
|
|
1288
|
+
if (cap && !String(cap).endsWith('%')) return PX(cap);
|
|
1289
|
+
return 0;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
977
1292
|
function themeFromParsedDoc(doc) {
|
|
978
1293
|
const theme = {};
|
|
979
1294
|
const body = doc.body;
|
|
@@ -985,9 +1300,7 @@ function themeFromParsedDoc(doc) {
|
|
|
985
1300
|
if (bg) theme.bg = bg;
|
|
986
1301
|
const widthCounts = {};
|
|
987
1302
|
body.querySelectorAll('table').forEach((tb) => {
|
|
988
|
-
const
|
|
989
|
-
if (!w || String(w).endsWith('%')) return;
|
|
990
|
-
const px = PX(w);
|
|
1303
|
+
const px = fixedWidthOf(tb);
|
|
991
1304
|
if (px >= 320 && px <= 900) widthCounts[px] = (widthCounts[px] || 0) + 1;
|
|
992
1305
|
});
|
|
993
1306
|
const bestWidth = Object.keys(widthCounts).sort((a, b) => widthCounts[b] - widthCounts[a])[0];
|
|
@@ -998,10 +1311,7 @@ function themeFromParsedDoc(doc) {
|
|
|
998
1311
|
// column's corner. Without this an import flattened both, and the next
|
|
999
1312
|
// export silently squared the template off and closed the gap around it.
|
|
1000
1313
|
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
|
-
});
|
|
1314
|
+
const content = Array.from(body.querySelectorAll('table')).find((tb) => fixedWidthOf(tb) === Number(bestWidth));
|
|
1005
1315
|
if (content) {
|
|
1006
1316
|
// The content column's own background -- including the literal
|
|
1007
1317
|
// `transparent` the exporter always writes for a see-through column.
|
package/src/core/sanitize.js
CHANGED
|
@@ -160,7 +160,14 @@ export const linkHref = (u) => {
|
|
|
160
160
|
if (!/^[a-z][a-z0-9+.-]*:\d/i.test(raw)) return '';
|
|
161
161
|
}
|
|
162
162
|
if (raw.startsWith('//')) return 'https:' + raw;
|
|
163
|
-
|
|
163
|
+
// A href that *is* a merge placeholder is handed back untouched. Only
|
|
164
|
+
// `{{ }}` used to be, so `[Survey URL]` -- and Mailchimp's `*|URL|*`, and
|
|
165
|
+
// `%%url%%`, and `${url}` -- came back as `https://[Survey URL]`, which the
|
|
166
|
+
// sending engine then expands into `https://https://...` and the link is
|
|
167
|
+
// dead. Anchored to the start on purpose: `example.com/{{id}}` is a real
|
|
168
|
+
// relative URL and still needs its scheme.
|
|
169
|
+
if (/^(?:\{\{|\[|%%|\*\||\$\{)/.test(raw)) return raw;
|
|
170
|
+
if (/^[#/]/.test(raw)) return raw;
|
|
164
171
|
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) return 'mailto:' + raw;
|
|
165
172
|
return 'https://' + raw;
|
|
166
173
|
};
|