@seliseblocks/mailcraft 0.1.0 → 0.2.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/DOCS.md +240 -12
- package/README.md +11 -3
- package/README.md.txt +134 -0
- package/dist/mailcraft-editor.bundle.js +51 -39
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/order-confirmed.html +80 -80
- package/examples/vanilla.html +242 -23
- package/package.json +7 -2
- package/src/core/assets.js +10 -15
- package/src/core/binder.js +120 -118
- package/src/core/blocks.js +14 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +1530 -1492
- package/src/core/export.js +142 -55
- package/src/core/i18n/ar.js +219 -177
- package/src/core/i18n/bg.js +196 -152
- package/src/core/i18n/bn.js +218 -176
- package/src/core/i18n/ca.js +196 -152
- package/src/core/i18n/cs.js +196 -152
- package/src/core/i18n/da.js +196 -152
- package/src/core/i18n/de-CH.js +196 -152
- package/src/core/i18n/de.js +196 -152
- package/src/core/i18n/dz.js +221 -179
- package/src/core/i18n/el.js +196 -152
- package/src/core/i18n/en.js +3 -10
- package/src/core/i18n/es.js +196 -152
- package/src/core/i18n/et.js +196 -152
- package/src/core/i18n/fi.js +196 -152
- package/src/core/i18n/fr.js +196 -152
- package/src/core/i18n/hr.js +196 -152
- package/src/core/i18n/hu.js +196 -152
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +196 -152
- package/src/core/i18n/lt.js +196 -152
- package/src/core/i18n/lv.js +196 -152
- package/src/core/i18n/nb.js +196 -152
- package/src/core/i18n/nl.js +196 -152
- package/src/core/i18n/pl.js +196 -152
- package/src/core/i18n/pt.js +196 -152
- package/src/core/i18n/ro.js +196 -152
- package/src/core/i18n/ru.js +196 -152
- package/src/core/i18n/sk.js +196 -152
- package/src/core/i18n/sl.js +196 -152
- package/src/core/i18n/sv.js +196 -152
- package/src/core/i18n/tables.js +50 -50
- package/src/core/i18n/tr.js +196 -152
- package/src/core/i18n/uk.js +196 -152
- package/src/core/icons.js +237 -235
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +1025 -959
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +141 -141
- package/src/core/storage-limits.js +184 -184
- package/src/core/storage.js +85 -85
- package/src/core/theme.js +1 -1
- package/src/core/variables.js +11 -11
- package/src/index.js +9 -9
- package/src/mailcraft-editor.js +26 -14
- package/src/render/block-body.js +49 -6
- package/src/render/canvas.js +31 -2
- package/src/render/fields.js +602 -588
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +241 -212
- package/src/render/screenshot.js +132 -132
- package/src/render/story.js +415 -415
- package/src/render/style.js +8 -0
- package/types/index.d.ts +419 -0
package/src/core/import-html.js
CHANGED
|
@@ -1,959 +1,1025 @@
|
|
|
1
|
-
import { mkRow, blk } from './blocks.js';
|
|
2
|
-
import { cleanImportHtml } from './sanitize.js';
|
|
3
|
-
import { inlineStylesheets } from './css-cascade.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* HTML -> doc importer. Mirrors the canonical shapes `render/block-body.js`
|
|
7
|
-
* renders (a table-based layout, one wrapping `<div>` per non-typographic
|
|
8
|
-
* block) so that MailCraft's own exported HTML round-trips back into the
|
|
9
|
-
* same block types it started as, while arbitrary/foreign email HTML
|
|
10
|
-
* degrades gracefully: anything recognizable becomes a native block,
|
|
11
|
-
* everything else becomes plain `text` (inline content) or a raw `html`
|
|
12
|
-
* block (structural content) rather than being dropped.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
function PX(v) {
|
|
16
|
-
const n = parseFloat(v);
|
|
17
|
-
return Number.isFinite(n) ? n : 0;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Font size only when absolute (px/pt/bare number). With the CSS cascade folding stylesheet rules inline, relative sizes reach the classifiers -- the classic `sub,sup{font-size:75%}` must not read as 75 pixels. */
|
|
21
|
-
function fontPx(v) {
|
|
22
|
-
const s = String(v || '').trim();
|
|
23
|
-
if (!s || s.endsWith('%') || s.endsWith('em')) return 0;
|
|
24
|
-
if (s.endsWith('pt')) return Math.round(PX(s) * 4 / 3);
|
|
25
|
-
return PX(s);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function onlyChild(el, tag) {
|
|
29
|
-
return el.children.length === 1 && el.firstElementChild.tagName === tag ? el.firstElementChild : null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function textAlignOf(el) {
|
|
33
|
-
let n = el;
|
|
34
|
-
while (n && n.nodeType === 1) {
|
|
35
|
-
// `align` on a TABLE floats the table itself (the classic
|
|
36
|
-
// `align="center"` content wrapper) and says nothing about text
|
|
37
|
-
// alignment inside it -- honoring it centered every left-aligned
|
|
38
|
-
// paragraph in a centered-layout email.
|
|
39
|
-
const a = (n.style && n.style.textAlign) || (n.tagName === 'TABLE' ? '' : n.getAttribute('align'));
|
|
40
|
-
if (a) return a;
|
|
41
|
-
n = n.parentElement;
|
|
42
|
-
}
|
|
43
|
-
return 'left';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** First value of a style property found walking from `el` up through its ancestors -- table emails set typography on the `<td>` and let cells inherit, so a block's base style usually lives above the content itself. */
|
|
47
|
-
function inheritedStyle(el, prop) {
|
|
48
|
-
let n = el;
|
|
49
|
-
while (n && n.nodeType === 1) {
|
|
50
|
-
const v = n.style && n.style[prop];
|
|
51
|
-
if (v) return v;
|
|
52
|
-
n = n.parentElement;
|
|
53
|
-
}
|
|
54
|
-
return '';
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Innermost fixed-pixel width among the element's table/td ancestors -- the closest thing source HTML offers to "how wide is this column", for converting a px-sized image into MailCraft's %-of-column width. A percent-width `<td>` on the way up (a 50% column between the image and the 680px content table) scales the eventual pixel answer, so an icon in a half-width column resolves against ~340px, not 680. A percent width on a `<table>` (the ubiquitous width="100%" block wrapper) says nothing and is skipped. */
|
|
58
|
-
function ancestorPxWidth(el) {
|
|
59
|
-
let n = el.parentElement;
|
|
60
|
-
let factor = 1;
|
|
61
|
-
while (n) {
|
|
62
|
-
if (n.tagName === 'TABLE' || n.tagName === 'TD') {
|
|
63
|
-
const w = n.getAttribute('width') || (n.style && n.style.width) || '';
|
|
64
|
-
if (w && String(w).endsWith('%')) {
|
|
65
|
-
const pct = PX(w);
|
|
66
|
-
if (n.tagName === 'TD' && pct > 0 && pct < 100) factor *= pct / 100;
|
|
67
|
-
} else if (w) {
|
|
68
|
-
const px = PX(w);
|
|
69
|
-
if (px >= 100) return Math.round(px * factor);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
n = n.parentElement;
|
|
73
|
-
}
|
|
74
|
-
return 0;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Legacy `cellpadding` on a block-wrapper table -- the padding source when the inner td carries no style padding at all. */
|
|
78
|
-
function cellPadOf(tb) {
|
|
79
|
-
const cp = tb && tb.getAttribute ? PX(tb.getAttribute('cellpadding')) : 0;
|
|
80
|
-
return cp ? { t: cp, b: cp, l: cp, r: cp, py: cp, px: cp } : null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Content invisible in a desktop mail render must not import as text:
|
|
85
|
-
* `display:none` (hidden preheaders, hamburger machinery, mobile-only
|
|
86
|
-
* mirrors), `visibility:hidden`, `opacity:0`, and the other preheader idiom
|
|
87
|
-
* -- collapsed to nothing via `max-height:0` with `overflow:hidden` or a
|
|
88
|
-
* zero font. A zero font-size ALONE is not hidden: emails set it on
|
|
89
|
-
* whitespace-collapsing wrappers around buttons and menus.
|
|
90
|
-
*/
|
|
91
|
-
function isHidden(el) {
|
|
92
|
-
const st = el.style;
|
|
93
|
-
if (!st) return false;
|
|
94
|
-
if (st.display === 'none' || st.visibility === 'hidden') return true;
|
|
95
|
-
if (st.opacity === '0') return true;
|
|
96
|
-
const collapsed = st.maxHeight === '0' || st.maxHeight === '0px';
|
|
97
|
-
const invisible = st.overflow === 'hidden' || st.fontSize === '0' || st.fontSize === '0px';
|
|
98
|
-
return collapsed && invisible;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** The url() of a background image declared on the element (style shorthand or longhand, or the legacy `background` attribute). A gradient-only background yields nothing. */
|
|
102
|
-
function bgImageOf(el) {
|
|
103
|
-
if (!el) return '';
|
|
104
|
-
const st = el.style;
|
|
105
|
-
if (st) {
|
|
106
|
-
const m = ((st.backgroundImage || '') + ' ' + (st.background || '')).match(/url\(["']?([^"')]+)["']?\)/);
|
|
107
|
-
if (m) return m[1];
|
|
108
|
-
}
|
|
109
|
-
return (el.getAttribute && el.getAttribute('background')) || '';
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Carries a wrapper's background image (hero photo sections) onto the rows it produced, mirroring applyBg -- fit/position/repeat come along when declared. */
|
|
113
|
-
function applyBgImage(rows, el) {
|
|
114
|
-
const url = bgImageOf(el);
|
|
115
|
-
if (!url) return rows;
|
|
116
|
-
const st = el.style || {};
|
|
117
|
-
rows.forEach((r) => {
|
|
118
|
-
if (r.props.bgImage) return;
|
|
119
|
-
r.props.bgImage = url;
|
|
120
|
-
if (st.backgroundSize) r.props.bgSize = st.backgroundSize;
|
|
121
|
-
if (st.backgroundPosition) r.props.bgPos = st.backgroundPosition;
|
|
122
|
-
if (st.backgroundRepeat) r.props.bgRepeat = st.backgroundRepeat;
|
|
123
|
-
});
|
|
124
|
-
return rows;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** 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
|
-
function paddingOf(st) {
|
|
129
|
-
if (!st) return null;
|
|
130
|
-
const t = PX(st.paddingTop); const b = PX(st.paddingBottom);
|
|
131
|
-
const l = PX(st.paddingLeft); const r = PX(st.paddingRight);
|
|
132
|
-
if (!t && !b && !l && !r) return null;
|
|
133
|
-
return { t, b, l, r, py: Math.round((t + b) / 2), px: Math.round((l + r) / 2) };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Rows can hold padding exactly: the linked py/px pair when the source is symmetric, plus per-side overrides (and the split flag, so the inspector opens showing four sliders) when it isn't. */
|
|
137
|
-
function setRowPad(row, pad) {
|
|
138
|
-
row.props.py = pad.py; row.props.px = pad.px;
|
|
139
|
-
if (pad.t !== pad.b || pad.l !== pad.r) {
|
|
140
|
-
row.props.pt = pad.t; row.props.pb = pad.b; row.props.pl = pad.l; row.props.pr = pad.r;
|
|
141
|
-
row.props.padSplit = true;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** MailCraft's `text` block treats `lh` as a bare multiplier of the block's own font size (e.g. `1.6`), same as CSS's unitless `line-height`. Source HTML overwhelmingly writes `line-height` in `px` (or `%`) instead, which is a different scale entirely -- reading `parseFloat('24px')` as `24` would hand the block a line height of 24x its font size. Converts to the multiplier MailCraft expects, and refuses anything that still doesn't look like one (a typo, a unitless value some tool emitted by mistake) rather than risk a blown-up line box. */
|
|
146
|
-
function lineHeightRatio(raw, fontPx) {
|
|
147
|
-
if (!raw) return null;
|
|
148
|
-
const size = fontPx || 16;
|
|
149
|
-
let ratio;
|
|
150
|
-
if (raw.endsWith('%')) ratio = PX(raw) / 100;
|
|
151
|
-
else if (raw.endsWith('em')) ratio = PX(raw); // already a multiplier of the font size, by definition
|
|
152
|
-
else if (raw.endsWith('pt')) ratio = (PX(raw) * 4 / 3) / size;
|
|
153
|
-
else if (raw.endsWith('px')) ratio = PX(raw) / size;
|
|
154
|
-
else ratio = parseFloat(raw);
|
|
155
|
-
return Number.isFinite(ratio) && ratio > 0 && ratio <= 4 ? ratio : null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function escapeText(s) {
|
|
159
|
-
const d = document.createElement('div');
|
|
160
|
-
d.textContent = s;
|
|
161
|
-
return d.innerHTML;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ---- per-shape classifiers (each returns a block, or null if it doesn't match) ----
|
|
165
|
-
|
|
166
|
-
function classifyImage(el) {
|
|
167
|
-
let img = null; let href = '';
|
|
168
|
-
if (el.tagName === 'IMG') img = el;
|
|
169
|
-
else if (el.tagName === 'A') {
|
|
170
|
-
img = onlyChild(el, 'IMG');
|
|
171
|
-
if (img) href = el.getAttribute('href') || '';
|
|
172
|
-
} else if (el.tagName === 'DIV' || el.tagName === 'TD') {
|
|
173
|
-
img = onlyChild(el, 'IMG');
|
|
174
|
-
if (!img) {
|
|
175
|
-
const a = onlyChild(el, 'A');
|
|
176
|
-
if (a) { img = onlyChild(a, 'IMG'); if (img) href = a.getAttribute('href') || ''; }
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (!img) return null;
|
|
180
|
-
// A pixel size wins over a percent one wherever either appears: builders
|
|
181
|
-
// routinely write `width:100%` on the img and put the real constraint on a
|
|
182
|
-
// `width` attribute or a wrapper's `max-width` (`<div style="max-width:88px">
|
|
183
|
-
// <img style="width:100%" width="88">`), and trusting the percent first is
|
|
184
|
-
// exactly what blew an 88px logo up to the full content width.
|
|
185
|
-
const isPct = (v) => String(v || '').endsWith('%');
|
|
186
|
-
const pxCandidates = [
|
|
187
|
-
img.style.width, img.getAttribute('width'), img.style.maxWidth,
|
|
188
|
-
el !== img && el.style ? el.style.width : '', el !== img && el.style ? el.style.maxWidth : '',
|
|
189
|
-
];
|
|
190
|
-
const pxHint = pxCandidates.find((v) => v && !isPct(v) && PX(v));
|
|
191
|
-
let width = 100;
|
|
192
|
-
if (pxHint) {
|
|
193
|
-
// Convert to % of the nearest fixed-width ancestor.
|
|
194
|
-
const colPx = ancestorPxWidth(img) || 600;
|
|
195
|
-
width = Math.max(2, Math.min(100, Math.round((PX(pxHint) / colPx) * 100)));
|
|
196
|
-
} else if (isPct(img.style.width) && PX(img.style.width)) {
|
|
197
|
-
width = PX(img.style.width);
|
|
198
|
-
}
|
|
199
|
-
return blk('image', {
|
|
200
|
-
src: img.getAttribute('src') || '',
|
|
201
|
-
alt: img.getAttribute('alt') || '',
|
|
202
|
-
href,
|
|
203
|
-
width,
|
|
204
|
-
align: textAlignOf(el),
|
|
205
|
-
// Always explicit: the block *default* is 10px, which quietly rounded
|
|
206
|
-
// the corners of every imported image that had none.
|
|
207
|
-
radius: PX(img.style.borderRadius) || 0,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function classifyButton(el) {
|
|
212
|
-
let a = null; let outerAlign = '';
|
|
213
|
-
if (el.tagName === 'A') a = el;
|
|
214
|
-
else if (el.tagName === 'DIV' || el.tagName === 'TD') {
|
|
215
|
-
a = onlyChild(el, 'A');
|
|
216
|
-
if (a) outerAlign = el.style.textAlign || el.getAttribute('align') || '';
|
|
217
|
-
}
|
|
218
|
-
if (!a) return null;
|
|
219
|
-
if (a.querySelector('img,table,div')) return null;
|
|
220
|
-
// The visual pill isn't always the anchor itself: bulletproof-button
|
|
221
|
-
// generators (Beefree et al.) leave the `<a>` bare and hang the background,
|
|
222
|
-
// radius and padding on nested `<span>`s inside it. Whichever element
|
|
223
|
-
// 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);
|
|
226
|
-
if (!pill) return null;
|
|
227
|
-
const st = pill.style;
|
|
228
|
-
let pad = paddingOf(a.style) || paddingOf(st);
|
|
229
|
-
if (!pad) {
|
|
230
|
-
const padded = Array.from(pill.querySelectorAll('span')).find((s) => paddingOf(s.style));
|
|
231
|
-
if (padded) pad = paddingOf(padded.style);
|
|
232
|
-
}
|
|
233
|
-
const display = st.display || a.style.display || '';
|
|
234
|
-
if (!pad && display.indexOf('inline-block') < 0 && display !== 'block') return null;
|
|
235
|
-
const over = {
|
|
236
|
-
label: (a.textContent || '').trim() || 'Button',
|
|
237
|
-
href: a.getAttribute('href') || '#',
|
|
238
|
-
bg: st.backgroundColor || st.background,
|
|
239
|
-
color: st.color || a.style.color || '#ffffff',
|
|
240
|
-
radius: radiusOf(st),
|
|
241
|
-
py: (pad && pad.py) || 13,
|
|
242
|
-
px: (pad && pad.px) || 26,
|
|
243
|
-
// The anchor's own `text-align` centers the *label inside the pill*
|
|
244
|
-
// (boilerplate on almost every bulletproof button) and says nothing
|
|
245
|
-
// about where the pill sits in the row -- that's the container's call,
|
|
246
|
-
// so alignment is read starting at the parent, never at the anchor.
|
|
247
|
-
align: outerAlign || textAlignOf(a.parentElement || a),
|
|
248
|
-
full: a.style.display === 'block',
|
|
249
|
-
};
|
|
250
|
-
const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
|
|
251
|
-
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; }
|
|
255
|
-
return blk('button', over);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const SOCIAL_HOSTS = /facebook|twitter|x\.com|instagram|linkedin|youtube|tiktok|pinterest|threads|whatsapp|telegram|github|discord|snapchat|medium|dribbble|behance|mastodon|bluesky|bsky/i;
|
|
259
|
-
|
|
260
|
-
/** A run of small image-links, mostly pointing at social networks (every footer's icon strip), becomes a native social block -- MailCraft draws its own icon art from the network name (alt text, or the link's hostname). Imported as loose images they'd neither line up nor be editable as a set. */
|
|
261
|
-
function classifySocial(el) {
|
|
262
|
-
if (!/^(DIV|TD|TABLE)$/.test(el.tagName)) return null;
|
|
263
|
-
if ((el.textContent || '').trim()) return null;
|
|
264
|
-
const anchors = Array.from(el.querySelectorAll('a'));
|
|
265
|
-
if (anchors.length < 2) return null;
|
|
266
|
-
// `svg` alongside `img`: MailCraft's own social block renders inline-SVG
|
|
267
|
-
// icons, so accepting both is what lets an exported strip round-trip back
|
|
268
|
-
// into a social block instead of dissolving.
|
|
269
|
-
const imgs = anchors.map((a) => a.querySelector('img,svg'));
|
|
270
|
-
if (imgs.some((im) => !im)) return null;
|
|
271
|
-
const sizes = imgs.map((im) => PX(im.getAttribute('width') || (im.style && im.style.width)) || 32);
|
|
272
|
-
if (sizes.some((s) => s > 64)) return null;
|
|
273
|
-
const names = anchors.map((a, i) => {
|
|
274
|
-
const named = (imgs[i].getAttribute('alt') || a.getAttribute('title') || a.getAttribute('aria-label') || '').trim();
|
|
275
|
-
if (named) return named;
|
|
276
|
-
const host = ((a.getAttribute('href') || '').match(/\/\/(?:www\.)?([^/?#]+)/) || [])[1] || '';
|
|
277
|
-
return host.split('.')[0] || 'link';
|
|
278
|
-
});
|
|
279
|
-
const social = anchors.filter((a, i) => SOCIAL_HOSTS.test((a.getAttribute('href') || '') + ' ' + names[i]));
|
|
280
|
-
if (social.length < Math.ceil(anchors.length / 2)) return null;
|
|
281
|
-
const a0 = anchors[0];
|
|
282
|
-
const over = {
|
|
283
|
-
items: anchors.map((a, i) => names[i] + '|' + (a.getAttribute('href') || '#')).join('\n'),
|
|
284
|
-
// From the first anchor's ancestry, not `el`: the alignment usually sits
|
|
285
|
-
// on an inner td between the icons and the table this classifier sees.
|
|
286
|
-
align: textAlignOf(a0),
|
|
287
|
-
size: Math.round(sizes.reduce((x, y) => x + y, 0) / sizes.length),
|
|
288
|
-
};
|
|
289
|
-
// Presentation from the strip's own styling where it exists (MailCraft's
|
|
290
|
-
// exported strips carry shape/color on the anchors). A foreign image-icon
|
|
291
|
-
// strip has none of it -- render those as bare, brand-colored glyphs, the
|
|
292
|
-
// closest match to how such strips actually look. The block *default*
|
|
293
|
-
// (outlined boxes at 1.9x the icon size) both looked wrong and wrapped in
|
|
294
|
-
// narrow footer columns.
|
|
295
|
-
const aBg = a0.style && (a0.style.backgroundColor || a0.style.background);
|
|
296
|
-
const aBorder = a0.style ? PX(a0.style.borderWidth) || PX(a0.style.borderTopWidth) : 0;
|
|
297
|
-
over.shape = aBg ? (String(a0.style.borderRadius || '').indexOf('50%') > -1 ? 'circle' : 'square') : (aBorder ? 'outline' : 'bare');
|
|
298
|
-
const aColor = a0.style && a0.style.color;
|
|
299
|
-
if (aColor) over.color = aColor; else over.palette = 'brand';
|
|
300
|
-
// Icon spacing: inter-cell padding on image strips, anchor margins on
|
|
301
|
-
// exported ones.
|
|
302
|
-
const cell = a0.closest ? a0.closest('td') : null;
|
|
303
|
-
const cellGap = cell && cell !== el && el.contains(cell) ? PX(cell.style.paddingRight) + PX(cell.style.paddingLeft) : 0;
|
|
304
|
-
const marginGap = a0.style ? PX(a0.style.marginRight) + PX(a0.style.marginLeft) : 0;
|
|
305
|
-
const gap = cellGap || marginGap;
|
|
306
|
-
if (gap) over.gap = gap;
|
|
307
|
-
return blk('social', over);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** A run of two or more sibling links with nothing else in the container (Beefree/MJML nav bars) is a menu block, not a text run -- as text, each link imports mid-paragraph with the wrapper's junk around it. */
|
|
311
|
-
function classifyMenu(el) {
|
|
312
|
-
if (el.tagName !== 'DIV' && el.tagName !== 'TD') return null;
|
|
313
|
-
if (el.querySelector('img,table,div,p,input,h1,h2,h3,h4,h5,h6')) return null;
|
|
314
|
-
const anchors = Array.from(el.children).filter((c) => c.tagName === 'A');
|
|
315
|
-
if (anchors.length < 2) return null;
|
|
316
|
-
const linkText = anchors.map((a) => (a.textContent || '')).join('').replace(/\s+/g, '');
|
|
317
|
-
if (linkText !== (el.textContent || '').replace(/\s+/g, '')) return null;
|
|
318
|
-
const over = {
|
|
319
|
-
items: anchors.map((a) => ((a.textContent || '').trim() + '|' + (a.getAttribute('href') || '#'))).join('\n'),
|
|
320
|
-
align: textAlignOf(el),
|
|
321
|
-
};
|
|
322
|
-
const size = fontPx(inheritedStyle(anchors[0], 'fontSize'));
|
|
323
|
-
if (size) over.size = size;
|
|
324
|
-
const color = inheritedStyle(anchors[0], 'color');
|
|
325
|
-
if (color) over.color = color;
|
|
326
|
-
return blk('menu', over);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function classifyDivider(el) {
|
|
330
|
-
if (el.tagName === 'HR') {
|
|
331
|
-
const sw = el.style.width || '';
|
|
332
|
-
return blk('divider', {
|
|
333
|
-
thickness: PX(el.style.height) || PX(el.style.borderTopWidth) || 1,
|
|
334
|
-
lineStyle: borderStyleOf(el.style),
|
|
335
|
-
color: el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor || '#d9dade',
|
|
336
|
-
width: sw.endsWith('%') ? PX(sw) : 100,
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
if (el.tagName !== 'DIV' && el.tagName !== 'TD') return null;
|
|
340
|
-
const bar = el.children.length === 1 ? el.firstElementChild : (!el.children.length ? el : null);
|
|
341
|
-
if (!bar || bar.tagName !== 'DIV' || bar.children.length || String(bar.textContent || '').trim()) return null;
|
|
342
|
-
const h = PX(bar.style.height) || PX(bar.style.borderTopWidth);
|
|
343
|
-
const bg = bar.style.backgroundColor || bar.style.background || bar.style.borderTopColor;
|
|
344
|
-
if (!(h > 0 && h <= 10 && bg)) return null;
|
|
345
|
-
const sw = bar.style.width || '';
|
|
346
|
-
return blk('divider', { thickness: h, lineStyle: borderStyleOf(bar.style), color: bg, width: sw.endsWith('%') ? PX(sw) : 100 });
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function classifySpacer(el) {
|
|
350
|
-
if (el.tagName !== 'DIV' || el.children.length || String(el.textContent || '').trim()) return null;
|
|
351
|
-
const h = PX(el.style.height);
|
|
352
|
-
if (!h) return null;
|
|
353
|
-
if (el.style.backgroundColor || el.style.background) return null;
|
|
354
|
-
return blk('spacer', { height: h });
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function classifyHeading(el) {
|
|
358
|
-
if (!/^H[1-6]$/.test(el.tagName)) return null;
|
|
359
|
-
const st = el.style;
|
|
360
|
-
const over = { text: (el.textContent || '').trim(), level: el.tagName.toLowerCase() };
|
|
361
|
-
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;
|
|
364
|
-
if (st.fontWeight) over.weight = st.fontWeight;
|
|
365
|
-
return blk('heading', over);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function classifyList(el) {
|
|
369
|
-
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());
|
|
371
|
-
if (!items.length) return null;
|
|
372
|
-
return blk('list', { items: items.join('\n'), ordered: el.tagName === 'OL' });
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function classifyTable(el) {
|
|
376
|
-
if (el.tagName !== 'TABLE') return null;
|
|
377
|
-
const rows = Array.from(el.querySelectorAll(':scope > tbody > tr, :scope > tr'));
|
|
378
|
-
if (!rows.length) return null;
|
|
379
|
-
if (el.querySelector('img, table')) return null;
|
|
380
|
-
const counts = rows.map((r) => r.children.length);
|
|
381
|
-
if (Math.min(...counts) < 2) return null;
|
|
382
|
-
const header = !!el.querySelector('th');
|
|
383
|
-
const data = rows.map((r) => Array.from(r.children).map((c) => (c.textContent || '').trim().replace(/\|/g, '/')).join('|')).join('\n');
|
|
384
|
-
const firstCell = rows[0] && rows[0].children[0];
|
|
385
|
-
const cellStyle = firstCell && firstCell.style;
|
|
386
|
-
const borderWidth = cellStyle ? borderSidesOf(cellStyle).width : 0;
|
|
387
|
-
const borders = !!borderWidth;
|
|
388
|
-
return blk('table', {
|
|
389
|
-
data, header, borders, borderWidth: borderWidth || 1,
|
|
390
|
-
borderStyle: cellStyle ? borderStyleOf(cellStyle) : 'solid',
|
|
391
|
-
lineColor: cellStyle ? (borderColorOf(cellStyle) || '#e2e8f0') : '#e2e8f0',
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu, classifyDivider, classifySpacer, classifyHeading, classifyList, classifyTable];
|
|
396
|
-
|
|
397
|
-
function classifyNode(el) {
|
|
398
|
-
for (const fn of CLASSIFIERS) {
|
|
399
|
-
const b = fn(el);
|
|
400
|
-
if (b) return b;
|
|
401
|
-
}
|
|
402
|
-
return null;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function isStructural(el) {
|
|
406
|
-
return /^(TABLE|FORM|IFRAME|SCRIPT|STYLE|VIDEO|OBJECT|EMBED)$/.test(el.tagName) || !!el.querySelector('table,form,iframe,script,video,object,embed');
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** `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
|
-
function unwrapBoxDiv(el) {
|
|
411
|
-
if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
|
|
412
|
-
const extra = Array.from(el.style).filter((prop) => prop.indexOf('margin') !== 0);
|
|
413
|
-
if (extra.length) return el;
|
|
414
|
-
const child = el.firstElementChild;
|
|
415
|
-
if ((el.textContent || '') !== (child.textContent || '')) return el;
|
|
416
|
-
return unwrapBoxDiv(child);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** Walks a column's (or the body's) child nodes and turns them into blocks -- recognized shapes become native blocks, runs of unrecognized inline content are buffered and flushed as a single sanitized `text` block, and structural content that can't be classified falls back to a raw `html` block. Never throws, never drops content. */
|
|
420
|
-
/** Tags whose inline styles describe a local run of characters, not the block around them -- a bold lead-in `<span>`'s weight must not become the whole block's weight. */
|
|
421
|
-
const INLINE_TAGS = /^(SPAN|A|B|STRONG|I|EM|U|S|STRIKE|CODE|SUP|SUB|FONT)$/;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/** A
|
|
562
|
-
function
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
*/
|
|
626
|
-
function
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1
|
+
import { mkRow, blk } from './blocks.js';
|
|
2
|
+
import { cleanImportHtml } from './sanitize.js';
|
|
3
|
+
import { inlineStylesheets } from './css-cascade.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTML -> doc importer. Mirrors the canonical shapes `render/block-body.js`
|
|
7
|
+
* renders (a table-based layout, one wrapping `<div>` per non-typographic
|
|
8
|
+
* block) so that MailCraft's own exported HTML round-trips back into the
|
|
9
|
+
* same block types it started as, while arbitrary/foreign email HTML
|
|
10
|
+
* degrades gracefully: anything recognizable becomes a native block,
|
|
11
|
+
* everything else becomes plain `text` (inline content) or a raw `html`
|
|
12
|
+
* block (structural content) rather than being dropped.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function PX(v) {
|
|
16
|
+
const n = parseFloat(v);
|
|
17
|
+
return Number.isFinite(n) ? n : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Font size only when absolute (px/pt/bare number). With the CSS cascade folding stylesheet rules inline, relative sizes reach the classifiers -- the classic `sub,sup{font-size:75%}` must not read as 75 pixels. */
|
|
21
|
+
function fontPx(v) {
|
|
22
|
+
const s = String(v || '').trim();
|
|
23
|
+
if (!s || s.endsWith('%') || s.endsWith('em')) return 0;
|
|
24
|
+
if (s.endsWith('pt')) return Math.round(PX(s) * 4 / 3);
|
|
25
|
+
return PX(s);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function onlyChild(el, tag) {
|
|
29
|
+
return el.children.length === 1 && el.firstElementChild.tagName === tag ? el.firstElementChild : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function textAlignOf(el) {
|
|
33
|
+
let n = el;
|
|
34
|
+
while (n && n.nodeType === 1) {
|
|
35
|
+
// `align` on a TABLE floats the table itself (the classic
|
|
36
|
+
// `align="center"` content wrapper) and says nothing about text
|
|
37
|
+
// alignment inside it -- honoring it centered every left-aligned
|
|
38
|
+
// paragraph in a centered-layout email.
|
|
39
|
+
const a = (n.style && n.style.textAlign) || (n.tagName === 'TABLE' ? '' : n.getAttribute('align'));
|
|
40
|
+
if (a) return a;
|
|
41
|
+
n = n.parentElement;
|
|
42
|
+
}
|
|
43
|
+
return 'left';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** First value of a style property found walking from `el` up through its ancestors -- table emails set typography on the `<td>` and let cells inherit, so a block's base style usually lives above the content itself. */
|
|
47
|
+
function inheritedStyle(el, prop) {
|
|
48
|
+
let n = el;
|
|
49
|
+
while (n && n.nodeType === 1) {
|
|
50
|
+
const v = n.style && n.style[prop];
|
|
51
|
+
if (v) return v;
|
|
52
|
+
n = n.parentElement;
|
|
53
|
+
}
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Innermost fixed-pixel width among the element's table/td ancestors -- the closest thing source HTML offers to "how wide is this column", for converting a px-sized image into MailCraft's %-of-column width. A percent-width `<td>` on the way up (a 50% column between the image and the 680px content table) scales the eventual pixel answer, so an icon in a half-width column resolves against ~340px, not 680. A percent width on a `<table>` (the ubiquitous width="100%" block wrapper) says nothing and is skipped. */
|
|
58
|
+
function ancestorPxWidth(el) {
|
|
59
|
+
let n = el.parentElement;
|
|
60
|
+
let factor = 1;
|
|
61
|
+
while (n) {
|
|
62
|
+
if (n.tagName === 'TABLE' || n.tagName === 'TD') {
|
|
63
|
+
const w = n.getAttribute('width') || (n.style && n.style.width) || '';
|
|
64
|
+
if (w && String(w).endsWith('%')) {
|
|
65
|
+
const pct = PX(w);
|
|
66
|
+
if (n.tagName === 'TD' && pct > 0 && pct < 100) factor *= pct / 100;
|
|
67
|
+
} else if (w) {
|
|
68
|
+
const px = PX(w);
|
|
69
|
+
if (px >= 100) return Math.round(px * factor);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
n = n.parentElement;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Legacy `cellpadding` on a block-wrapper table -- the padding source when the inner td carries no style padding at all. */
|
|
78
|
+
function cellPadOf(tb) {
|
|
79
|
+
const cp = tb && tb.getAttribute ? PX(tb.getAttribute('cellpadding')) : 0;
|
|
80
|
+
return cp ? { t: cp, b: cp, l: cp, r: cp, py: cp, px: cp } : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Content invisible in a desktop mail render must not import as text:
|
|
85
|
+
* `display:none` (hidden preheaders, hamburger machinery, mobile-only
|
|
86
|
+
* mirrors), `visibility:hidden`, `opacity:0`, and the other preheader idiom
|
|
87
|
+
* -- collapsed to nothing via `max-height:0` with `overflow:hidden` or a
|
|
88
|
+
* zero font. A zero font-size ALONE is not hidden: emails set it on
|
|
89
|
+
* whitespace-collapsing wrappers around buttons and menus.
|
|
90
|
+
*/
|
|
91
|
+
function isHidden(el) {
|
|
92
|
+
const st = el.style;
|
|
93
|
+
if (!st) return false;
|
|
94
|
+
if (st.display === 'none' || st.visibility === 'hidden') return true;
|
|
95
|
+
if (st.opacity === '0') return true;
|
|
96
|
+
const collapsed = st.maxHeight === '0' || st.maxHeight === '0px';
|
|
97
|
+
const invisible = st.overflow === 'hidden' || st.fontSize === '0' || st.fontSize === '0px';
|
|
98
|
+
return collapsed && invisible;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The url() of a background image declared on the element (style shorthand or longhand, or the legacy `background` attribute). A gradient-only background yields nothing. */
|
|
102
|
+
function bgImageOf(el) {
|
|
103
|
+
if (!el) return '';
|
|
104
|
+
const st = el.style;
|
|
105
|
+
if (st) {
|
|
106
|
+
const m = ((st.backgroundImage || '') + ' ' + (st.background || '')).match(/url\(["']?([^"')]+)["']?\)/);
|
|
107
|
+
if (m) return m[1];
|
|
108
|
+
}
|
|
109
|
+
return (el.getAttribute && el.getAttribute('background')) || '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Carries a wrapper's background image (hero photo sections) onto the rows it produced, mirroring applyBg -- fit/position/repeat come along when declared. */
|
|
113
|
+
function applyBgImage(rows, el) {
|
|
114
|
+
const url = bgImageOf(el);
|
|
115
|
+
if (!url) return rows;
|
|
116
|
+
const st = el.style || {};
|
|
117
|
+
rows.forEach((r) => {
|
|
118
|
+
if (r.props.bgImage) return;
|
|
119
|
+
r.props.bgImage = url;
|
|
120
|
+
if (st.backgroundSize) r.props.bgSize = st.backgroundSize;
|
|
121
|
+
if (st.backgroundPosition) r.props.bgPos = st.backgroundPosition;
|
|
122
|
+
if (st.backgroundRepeat) r.props.bgRepeat = st.backgroundRepeat;
|
|
123
|
+
});
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 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
|
+
function paddingOf(st) {
|
|
129
|
+
if (!st) return null;
|
|
130
|
+
const t = PX(st.paddingTop); const b = PX(st.paddingBottom);
|
|
131
|
+
const l = PX(st.paddingLeft); const r = PX(st.paddingRight);
|
|
132
|
+
if (!t && !b && !l && !r) return null;
|
|
133
|
+
return { t, b, l, r, py: Math.round((t + b) / 2), px: Math.round((l + r) / 2) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Rows can hold padding exactly: the linked py/px pair when the source is symmetric, plus per-side overrides (and the split flag, so the inspector opens showing four sliders) when it isn't. */
|
|
137
|
+
function setRowPad(row, pad) {
|
|
138
|
+
row.props.py = pad.py; row.props.px = pad.px;
|
|
139
|
+
if (pad.t !== pad.b || pad.l !== pad.r) {
|
|
140
|
+
row.props.pt = pad.t; row.props.pb = pad.b; row.props.pl = pad.l; row.props.pr = pad.r;
|
|
141
|
+
row.props.padSplit = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** MailCraft's `text` block treats `lh` as a bare multiplier of the block's own font size (e.g. `1.6`), same as CSS's unitless `line-height`. Source HTML overwhelmingly writes `line-height` in `px` (or `%`) instead, which is a different scale entirely -- reading `parseFloat('24px')` as `24` would hand the block a line height of 24x its font size. Converts to the multiplier MailCraft expects, and refuses anything that still doesn't look like one (a typo, a unitless value some tool emitted by mistake) rather than risk a blown-up line box. */
|
|
146
|
+
function lineHeightRatio(raw, fontPx) {
|
|
147
|
+
if (!raw) return null;
|
|
148
|
+
const size = fontPx || 16;
|
|
149
|
+
let ratio;
|
|
150
|
+
if (raw.endsWith('%')) ratio = PX(raw) / 100;
|
|
151
|
+
else if (raw.endsWith('em')) ratio = PX(raw); // already a multiplier of the font size, by definition
|
|
152
|
+
else if (raw.endsWith('pt')) ratio = (PX(raw) * 4 / 3) / size;
|
|
153
|
+
else if (raw.endsWith('px')) ratio = PX(raw) / size;
|
|
154
|
+
else ratio = parseFloat(raw);
|
|
155
|
+
return Number.isFinite(ratio) && ratio > 0 && ratio <= 4 ? ratio : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function escapeText(s) {
|
|
159
|
+
const d = document.createElement('div');
|
|
160
|
+
d.textContent = s;
|
|
161
|
+
return d.innerHTML;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- per-shape classifiers (each returns a block, or null if it doesn't match) ----
|
|
165
|
+
|
|
166
|
+
function classifyImage(el) {
|
|
167
|
+
let img = null; let href = '';
|
|
168
|
+
if (el.tagName === 'IMG') img = el;
|
|
169
|
+
else if (el.tagName === 'A') {
|
|
170
|
+
img = onlyChild(el, 'IMG');
|
|
171
|
+
if (img) href = el.getAttribute('href') || '';
|
|
172
|
+
} else if (el.tagName === 'DIV' || el.tagName === 'TD') {
|
|
173
|
+
img = onlyChild(el, 'IMG');
|
|
174
|
+
if (!img) {
|
|
175
|
+
const a = onlyChild(el, 'A');
|
|
176
|
+
if (a) { img = onlyChild(a, 'IMG'); if (img) href = a.getAttribute('href') || ''; }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!img) return null;
|
|
180
|
+
// A pixel size wins over a percent one wherever either appears: builders
|
|
181
|
+
// routinely write `width:100%` on the img and put the real constraint on a
|
|
182
|
+
// `width` attribute or a wrapper's `max-width` (`<div style="max-width:88px">
|
|
183
|
+
// <img style="width:100%" width="88">`), and trusting the percent first is
|
|
184
|
+
// exactly what blew an 88px logo up to the full content width.
|
|
185
|
+
const isPct = (v) => String(v || '').endsWith('%');
|
|
186
|
+
const pxCandidates = [
|
|
187
|
+
img.style.width, img.getAttribute('width'), img.style.maxWidth,
|
|
188
|
+
el !== img && el.style ? el.style.width : '', el !== img && el.style ? el.style.maxWidth : '',
|
|
189
|
+
];
|
|
190
|
+
const pxHint = pxCandidates.find((v) => v && !isPct(v) && PX(v));
|
|
191
|
+
let width = 100;
|
|
192
|
+
if (pxHint) {
|
|
193
|
+
// Convert to % of the nearest fixed-width ancestor.
|
|
194
|
+
const colPx = ancestorPxWidth(img) || 600;
|
|
195
|
+
width = Math.max(2, Math.min(100, Math.round((PX(pxHint) / colPx) * 100)));
|
|
196
|
+
} else if (isPct(img.style.width) && PX(img.style.width)) {
|
|
197
|
+
width = PX(img.style.width);
|
|
198
|
+
}
|
|
199
|
+
return blk('image', {
|
|
200
|
+
src: img.getAttribute('src') || '',
|
|
201
|
+
alt: img.getAttribute('alt') || '',
|
|
202
|
+
href,
|
|
203
|
+
width,
|
|
204
|
+
align: textAlignOf(el),
|
|
205
|
+
// Always explicit: the block *default* is 10px, which quietly rounded
|
|
206
|
+
// the corners of every imported image that had none.
|
|
207
|
+
radius: PX(img.style.borderRadius) || 0,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function classifyButton(el) {
|
|
212
|
+
let a = null; let outerAlign = '';
|
|
213
|
+
if (el.tagName === 'A') a = el;
|
|
214
|
+
else if (el.tagName === 'DIV' || el.tagName === 'TD') {
|
|
215
|
+
a = onlyChild(el, 'A');
|
|
216
|
+
if (a) outerAlign = el.style.textAlign || el.getAttribute('align') || '';
|
|
217
|
+
}
|
|
218
|
+
if (!a) return null;
|
|
219
|
+
if (a.querySelector('img,table,div')) return null;
|
|
220
|
+
// The visual pill isn't always the anchor itself: bulletproof-button
|
|
221
|
+
// generators (Beefree et al.) leave the `<a>` bare and hang the background,
|
|
222
|
+
// radius and padding on nested `<span>`s inside it. Whichever element
|
|
223
|
+
// 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);
|
|
226
|
+
if (!pill) return null;
|
|
227
|
+
const st = pill.style;
|
|
228
|
+
let pad = paddingOf(a.style) || paddingOf(st);
|
|
229
|
+
if (!pad) {
|
|
230
|
+
const padded = Array.from(pill.querySelectorAll('span')).find((s) => paddingOf(s.style));
|
|
231
|
+
if (padded) pad = paddingOf(padded.style);
|
|
232
|
+
}
|
|
233
|
+
const display = st.display || a.style.display || '';
|
|
234
|
+
if (!pad && display.indexOf('inline-block') < 0 && display !== 'block') return null;
|
|
235
|
+
const over = {
|
|
236
|
+
label: (a.textContent || '').trim() || 'Button',
|
|
237
|
+
href: a.getAttribute('href') || '#',
|
|
238
|
+
bg: st.backgroundColor || st.background,
|
|
239
|
+
color: st.color || a.style.color || '#ffffff',
|
|
240
|
+
radius: radiusOf(st),
|
|
241
|
+
py: (pad && pad.py) || 13,
|
|
242
|
+
px: (pad && pad.px) || 26,
|
|
243
|
+
// The anchor's own `text-align` centers the *label inside the pill*
|
|
244
|
+
// (boilerplate on almost every bulletproof button) and says nothing
|
|
245
|
+
// about where the pill sits in the row -- that's the container's call,
|
|
246
|
+
// so alignment is read starting at the parent, never at the anchor.
|
|
247
|
+
align: outerAlign || textAlignOf(a.parentElement || a),
|
|
248
|
+
full: a.style.display === 'block',
|
|
249
|
+
};
|
|
250
|
+
const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
|
|
251
|
+
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; }
|
|
255
|
+
return blk('button', over);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const SOCIAL_HOSTS = /facebook|twitter|x\.com|instagram|linkedin|youtube|tiktok|pinterest|threads|whatsapp|telegram|github|discord|snapchat|medium|dribbble|behance|mastodon|bluesky|bsky/i;
|
|
259
|
+
|
|
260
|
+
/** A run of small image-links, mostly pointing at social networks (every footer's icon strip), becomes a native social block -- MailCraft draws its own icon art from the network name (alt text, or the link's hostname). Imported as loose images they'd neither line up nor be editable as a set. */
|
|
261
|
+
function classifySocial(el) {
|
|
262
|
+
if (!/^(DIV|TD|TABLE)$/.test(el.tagName)) return null;
|
|
263
|
+
if ((el.textContent || '').trim()) return null;
|
|
264
|
+
const anchors = Array.from(el.querySelectorAll('a'));
|
|
265
|
+
if (anchors.length < 2) return null;
|
|
266
|
+
// `svg` alongside `img`: MailCraft's own social block renders inline-SVG
|
|
267
|
+
// icons, so accepting both is what lets an exported strip round-trip back
|
|
268
|
+
// into a social block instead of dissolving.
|
|
269
|
+
const imgs = anchors.map((a) => a.querySelector('img,svg'));
|
|
270
|
+
if (imgs.some((im) => !im)) return null;
|
|
271
|
+
const sizes = imgs.map((im) => PX(im.getAttribute('width') || (im.style && im.style.width)) || 32);
|
|
272
|
+
if (sizes.some((s) => s > 64)) return null;
|
|
273
|
+
const names = anchors.map((a, i) => {
|
|
274
|
+
const named = (imgs[i].getAttribute('alt') || a.getAttribute('title') || a.getAttribute('aria-label') || '').trim();
|
|
275
|
+
if (named) return named;
|
|
276
|
+
const host = ((a.getAttribute('href') || '').match(/\/\/(?:www\.)?([^/?#]+)/) || [])[1] || '';
|
|
277
|
+
return host.split('.')[0] || 'link';
|
|
278
|
+
});
|
|
279
|
+
const social = anchors.filter((a, i) => SOCIAL_HOSTS.test((a.getAttribute('href') || '') + ' ' + names[i]));
|
|
280
|
+
if (social.length < Math.ceil(anchors.length / 2)) return null;
|
|
281
|
+
const a0 = anchors[0];
|
|
282
|
+
const over = {
|
|
283
|
+
items: anchors.map((a, i) => names[i] + '|' + (a.getAttribute('href') || '#')).join('\n'),
|
|
284
|
+
// From the first anchor's ancestry, not `el`: the alignment usually sits
|
|
285
|
+
// on an inner td between the icons and the table this classifier sees.
|
|
286
|
+
align: textAlignOf(a0),
|
|
287
|
+
size: Math.round(sizes.reduce((x, y) => x + y, 0) / sizes.length),
|
|
288
|
+
};
|
|
289
|
+
// Presentation from the strip's own styling where it exists (MailCraft's
|
|
290
|
+
// exported strips carry shape/color on the anchors). A foreign image-icon
|
|
291
|
+
// strip has none of it -- render those as bare, brand-colored glyphs, the
|
|
292
|
+
// closest match to how such strips actually look. The block *default*
|
|
293
|
+
// (outlined boxes at 1.9x the icon size) both looked wrong and wrapped in
|
|
294
|
+
// narrow footer columns.
|
|
295
|
+
const aBg = a0.style && (a0.style.backgroundColor || a0.style.background);
|
|
296
|
+
const aBorder = a0.style ? PX(a0.style.borderWidth) || PX(a0.style.borderTopWidth) : 0;
|
|
297
|
+
over.shape = aBg ? (String(a0.style.borderRadius || '').indexOf('50%') > -1 ? 'circle' : 'square') : (aBorder ? 'outline' : 'bare');
|
|
298
|
+
const aColor = a0.style && a0.style.color;
|
|
299
|
+
if (aColor) over.color = aColor; else over.palette = 'brand';
|
|
300
|
+
// Icon spacing: inter-cell padding on image strips, anchor margins on
|
|
301
|
+
// exported ones.
|
|
302
|
+
const cell = a0.closest ? a0.closest('td') : null;
|
|
303
|
+
const cellGap = cell && cell !== el && el.contains(cell) ? PX(cell.style.paddingRight) + PX(cell.style.paddingLeft) : 0;
|
|
304
|
+
const marginGap = a0.style ? PX(a0.style.marginRight) + PX(a0.style.marginLeft) : 0;
|
|
305
|
+
const gap = cellGap || marginGap;
|
|
306
|
+
if (gap) over.gap = gap;
|
|
307
|
+
return blk('social', over);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A run of two or more sibling links with nothing else in the container (Beefree/MJML nav bars) is a menu block, not a text run -- as text, each link imports mid-paragraph with the wrapper's junk around it. */
|
|
311
|
+
function classifyMenu(el) {
|
|
312
|
+
if (el.tagName !== 'DIV' && el.tagName !== 'TD') return null;
|
|
313
|
+
if (el.querySelector('img,table,div,p,input,h1,h2,h3,h4,h5,h6')) return null;
|
|
314
|
+
const anchors = Array.from(el.children).filter((c) => c.tagName === 'A');
|
|
315
|
+
if (anchors.length < 2) return null;
|
|
316
|
+
const linkText = anchors.map((a) => (a.textContent || '')).join('').replace(/\s+/g, '');
|
|
317
|
+
if (linkText !== (el.textContent || '').replace(/\s+/g, '')) return null;
|
|
318
|
+
const over = {
|
|
319
|
+
items: anchors.map((a) => ((a.textContent || '').trim() + '|' + (a.getAttribute('href') || '#'))).join('\n'),
|
|
320
|
+
align: textAlignOf(el),
|
|
321
|
+
};
|
|
322
|
+
const size = fontPx(inheritedStyle(anchors[0], 'fontSize'));
|
|
323
|
+
if (size) over.size = size;
|
|
324
|
+
const color = inheritedStyle(anchors[0], 'color');
|
|
325
|
+
if (color) over.color = color;
|
|
326
|
+
return blk('menu', over);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function classifyDivider(el) {
|
|
330
|
+
if (el.tagName === 'HR') {
|
|
331
|
+
const sw = el.style.width || '';
|
|
332
|
+
return blk('divider', {
|
|
333
|
+
thickness: PX(el.style.height) || PX(el.style.borderTopWidth) || 1,
|
|
334
|
+
lineStyle: borderStyleOf(el.style),
|
|
335
|
+
color: el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor || '#d9dade',
|
|
336
|
+
width: sw.endsWith('%') ? PX(sw) : 100,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (el.tagName !== 'DIV' && el.tagName !== 'TD') return null;
|
|
340
|
+
const bar = el.children.length === 1 ? el.firstElementChild : (!el.children.length ? el : null);
|
|
341
|
+
if (!bar || bar.tagName !== 'DIV' || bar.children.length || String(bar.textContent || '').trim()) return null;
|
|
342
|
+
const h = PX(bar.style.height) || PX(bar.style.borderTopWidth);
|
|
343
|
+
const bg = bar.style.backgroundColor || bar.style.background || bar.style.borderTopColor;
|
|
344
|
+
if (!(h > 0 && h <= 10 && bg)) return null;
|
|
345
|
+
const sw = bar.style.width || '';
|
|
346
|
+
return blk('divider', { thickness: h, lineStyle: borderStyleOf(bar.style), color: bg, width: sw.endsWith('%') ? PX(sw) : 100 });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function classifySpacer(el) {
|
|
350
|
+
if (el.tagName !== 'DIV' || el.children.length || String(el.textContent || '').trim()) return null;
|
|
351
|
+
const h = PX(el.style.height);
|
|
352
|
+
if (!h) return null;
|
|
353
|
+
if (el.style.backgroundColor || el.style.background) return null;
|
|
354
|
+
return blk('spacer', { height: h });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function classifyHeading(el) {
|
|
358
|
+
if (!/^H[1-6]$/.test(el.tagName)) return null;
|
|
359
|
+
const st = el.style;
|
|
360
|
+
const over = { text: (el.textContent || '').trim(), level: el.tagName.toLowerCase() };
|
|
361
|
+
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;
|
|
364
|
+
if (st.fontWeight) over.weight = st.fontWeight;
|
|
365
|
+
return blk('heading', over);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function classifyList(el) {
|
|
369
|
+
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());
|
|
371
|
+
if (!items.length) return null;
|
|
372
|
+
return blk('list', { items: items.join('\n'), ordered: el.tagName === 'OL' });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function classifyTable(el) {
|
|
376
|
+
if (el.tagName !== 'TABLE') return null;
|
|
377
|
+
const rows = Array.from(el.querySelectorAll(':scope > tbody > tr, :scope > tr'));
|
|
378
|
+
if (!rows.length) return null;
|
|
379
|
+
if (el.querySelector('img, table')) return null;
|
|
380
|
+
const counts = rows.map((r) => r.children.length);
|
|
381
|
+
if (Math.min(...counts) < 2) return null;
|
|
382
|
+
const header = !!el.querySelector('th');
|
|
383
|
+
const data = rows.map((r) => Array.from(r.children).map((c) => (c.textContent || '').trim().replace(/\|/g, '/')).join('|')).join('\n');
|
|
384
|
+
const firstCell = rows[0] && rows[0].children[0];
|
|
385
|
+
const cellStyle = firstCell && firstCell.style;
|
|
386
|
+
const borderWidth = cellStyle ? borderSidesOf(cellStyle).width : 0;
|
|
387
|
+
const borders = !!borderWidth;
|
|
388
|
+
return blk('table', {
|
|
389
|
+
data, header, borders, borderWidth: borderWidth || 1,
|
|
390
|
+
borderStyle: cellStyle ? borderStyleOf(cellStyle) : 'solid',
|
|
391
|
+
lineColor: cellStyle ? (borderColorOf(cellStyle) || '#e2e8f0') : '#e2e8f0',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu, classifyDivider, classifySpacer, classifyHeading, classifyList, classifyTable];
|
|
396
|
+
|
|
397
|
+
function classifyNode(el) {
|
|
398
|
+
for (const fn of CLASSIFIERS) {
|
|
399
|
+
const b = fn(el);
|
|
400
|
+
if (b) return b;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function isStructural(el) {
|
|
406
|
+
return /^(TABLE|FORM|IFRAME|SCRIPT|STYLE|VIDEO|OBJECT|EMBED)$/.test(el.tagName) || !!el.querySelector('table,form,iframe,script,video,object,embed');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** `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
|
+
function unwrapBoxDiv(el) {
|
|
411
|
+
if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
|
|
412
|
+
const extra = Array.from(el.style).filter((prop) => prop.indexOf('margin') !== 0);
|
|
413
|
+
if (extra.length) return el;
|
|
414
|
+
const child = el.firstElementChild;
|
|
415
|
+
if ((el.textContent || '') !== (child.textContent || '')) return el;
|
|
416
|
+
return unwrapBoxDiv(child);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Walks a column's (or the body's) child nodes and turns them into blocks -- recognized shapes become native blocks, runs of unrecognized inline content are buffered and flushed as a single sanitized `text` block, and structural content that can't be classified falls back to a raw `html` block. Never throws, never drops content. */
|
|
420
|
+
/** Tags whose inline styles describe a local run of characters, not the block around them -- a bold lead-in `<span>`'s weight must not become the whole block's weight. */
|
|
421
|
+
const INLINE_TAGS = /^(SPAN|A|B|STRONG|I|EM|U|S|STRIKE|CODE|SUP|SUB|FONT)$/;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* A text run that is nothing but dynamic-content tags -- what the exporter
|
|
425
|
+
* writes for marker blocks inside a column -- becomes those marker blocks
|
|
426
|
+
* again. Consecutive markers export joined by whitespace into one text node,
|
|
427
|
+
* so every line must parse or none do: mixed prose keeps its tags as
|
|
428
|
+
* pass-through content (an inline {{#if}} inside a sentence is the user's
|
|
429
|
+
* text, not row structure), exactly like merge tags.
|
|
430
|
+
*/
|
|
431
|
+
function logicMarkersOf(text) {
|
|
432
|
+
const lines = String(text || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
433
|
+
if (!lines.length) return null;
|
|
434
|
+
const out = [];
|
|
435
|
+
for (const line of lines) {
|
|
436
|
+
let m = line.match(/^\{\{#(if|each)\s+([^{}]+?)\s*\}\}$/);
|
|
437
|
+
if (m) { out.push(blk(m[1] === 'if' ? 'condition' : 'loop', { expr: m[2], end: false })); continue; }
|
|
438
|
+
m = line.match(/^\{\{\/(if|each)\}\}$/);
|
|
439
|
+
if (m) { out.push(blk(m[1] === 'if' ? 'condition' : 'loop', { expr: '', end: true })); continue; }
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function blocksFromNodes(nodes) {
|
|
446
|
+
const out = [];
|
|
447
|
+
let buf = [];
|
|
448
|
+
let bufFirstEl = null;
|
|
449
|
+
const flush = () => {
|
|
450
|
+
const html = cleanImportHtml(buf.join(''));
|
|
451
|
+
if (html) {
|
|
452
|
+
const over = { html };
|
|
453
|
+
if (bufFirstEl) {
|
|
454
|
+
// The block's *base* style: the first buffered element when it's a
|
|
455
|
+
// block-level thing (a styled <p>/<div> speaks for the run), else its
|
|
456
|
+
// ancestors -- table emails hang typography on the containing <td>
|
|
457
|
+
// (`font-size:12px` on a footer cell), which the old
|
|
458
|
+
// first-element-only read never saw. Per-element differences inside
|
|
459
|
+
// the run survive as the inline styles `cleanImportHtml` now keeps.
|
|
460
|
+
let baseEl = INLINE_TAGS.test(bufFirstEl.tagName) ? (bufFirstEl.parentElement || bufFirstEl) : bufFirstEl;
|
|
461
|
+
// Then descend through transparent single-child wrappers: builders
|
|
462
|
+
// nest a `font-family:sans-serif` shim div around the div that
|
|
463
|
+
// carries the real typography, and `inheritedStyle` below walks *up*
|
|
464
|
+
// from wherever this lands -- so start at the deepest element that
|
|
465
|
+
// still spans the whole run.
|
|
466
|
+
while (
|
|
467
|
+
baseEl.children.length === 1
|
|
468
|
+
&& !INLINE_TAGS.test(baseEl.firstElementChild.tagName)
|
|
469
|
+
&& (baseEl.textContent || '') === (baseEl.firstElementChild.textContent || '')
|
|
470
|
+
) baseEl = baseEl.firstElementChild;
|
|
471
|
+
const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
|
|
472
|
+
const color = inheritedStyle(baseEl, 'color'); if (color) over.color = color;
|
|
473
|
+
over.align = textAlignOf(baseEl);
|
|
474
|
+
const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
|
|
475
|
+
const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
|
|
476
|
+
if (lh) over.lh = lh;
|
|
477
|
+
}
|
|
478
|
+
out.push(blk('text', over));
|
|
479
|
+
}
|
|
480
|
+
buf = []; bufFirstEl = null;
|
|
481
|
+
};
|
|
482
|
+
nodes.forEach((n) => {
|
|
483
|
+
if (n.nodeType === 3) {
|
|
484
|
+
const markers = logicMarkersOf(n.textContent);
|
|
485
|
+
if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
|
|
486
|
+
if (n.textContent && n.textContent.trim()) buf.push(escapeText(n.textContent));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (n.nodeType !== 1) return;
|
|
490
|
+
if (isHidden(n)) return;
|
|
491
|
+
const target = unwrapBoxDiv(n);
|
|
492
|
+
const b = classifyNode(target);
|
|
493
|
+
if (b) { flush(); out.push(b); return; }
|
|
494
|
+
// A one-cell scaffolding table inside a column (Beefree's per-block
|
|
495
|
+
// `table.text_block` / `image_block` wrappers) is transparent here just
|
|
496
|
+
// as it is at row level -- without this it fell through to `isStructural`
|
|
497
|
+
// and the whole cell imported as one opaque raw-html block. The wrapping
|
|
498
|
+
// `td.pad`'s padding is the block's spacing, so it lands on the produced
|
|
499
|
+
// blocks' own py/px -- except buttons, where py/px mean the pill's inner
|
|
500
|
+
// padding, not outer spacing.
|
|
501
|
+
if (target.tagName === 'TABLE' && isPassthroughTable(target)) {
|
|
502
|
+
flush();
|
|
503
|
+
const tr = target.querySelector(':scope > tbody > tr, :scope > tr');
|
|
504
|
+
const td = Array.from(tr.children).find((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
505
|
+
const pad = paddingOf(td.style) || cellPadOf(target);
|
|
506
|
+
const inner = blocksFromNodes(Array.from(td.childNodes));
|
|
507
|
+
if (pad) inner.forEach((ib) => {
|
|
508
|
+
if (ib.type === 'button') return;
|
|
509
|
+
if ('py' in ib.props) ib.props.py = pad.py;
|
|
510
|
+
if ('px' in ib.props) ib.props.px = pad.px;
|
|
511
|
+
});
|
|
512
|
+
out.push(...inner);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (isStructural(target)) { flush(); out.push(blk('html', { code: n.outerHTML })); return; }
|
|
516
|
+
if (!bufFirstEl) bufFirstEl = target;
|
|
517
|
+
buf.push(n.outerHTML);
|
|
518
|
+
});
|
|
519
|
+
flush();
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function normalizeSpans(spans) {
|
|
524
|
+
const total = spans.reduce((a, v) => a + v, 0) || 1;
|
|
525
|
+
const scaled = spans.map((v) => Math.round((v / total) * 100));
|
|
526
|
+
scaled[scaled.length - 1] += 100 - scaled.reduce((a, v) => a + v, 0);
|
|
527
|
+
return scaled;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function spansFromCells(cells, tableWidthPx) {
|
|
531
|
+
const parsed = cells.map((td) => {
|
|
532
|
+
const sw = td.style.width || '';
|
|
533
|
+
if (sw.endsWith('%')) return { pct: PX(sw) };
|
|
534
|
+
const aw = td.getAttribute('width') || '';
|
|
535
|
+
if (aw.endsWith('%')) return { pct: PX(aw) };
|
|
536
|
+
const px = PX(sw) || PX(aw);
|
|
537
|
+
return px ? { px } : null;
|
|
538
|
+
});
|
|
539
|
+
if (parsed.every((p) => p && p.pct != null)) {
|
|
540
|
+
const total = parsed.reduce((a, p) => a + p.pct, 0);
|
|
541
|
+
if (total >= 90 && total <= 110) return normalizeSpans(parsed.map((p) => p.pct));
|
|
542
|
+
}
|
|
543
|
+
if (tableWidthPx && parsed.every((p) => p && p.px)) return normalizeSpans(parsed.map((p) => Math.round((p.px / tableWidthPx) * 100)));
|
|
544
|
+
const even = Math.floor(100 / cells.length);
|
|
545
|
+
const spans = cells.map(() => even);
|
|
546
|
+
spans[spans.length - 1] += 100 - even * cells.length;
|
|
547
|
+
return spans;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** 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. */
|
|
551
|
+
function unwrapNestedLayout(td) {
|
|
552
|
+
const only = onlyChild(td, 'TABLE');
|
|
553
|
+
if (!only) return null;
|
|
554
|
+
const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
|
|
555
|
+
if (trs.length !== 1) return null;
|
|
556
|
+
if (only.querySelector('th')) return null;
|
|
557
|
+
const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
558
|
+
return cells.length ? cells : null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** A table with exactly one `<tr>` and one cell is pure scaffolding (a per-section wrapper, an Outlook-only shim, a bulletproof-button frame) -- it carries no row/column intent of its own, so it should be seen through rather than turned into a one-cell row. Real email builders (MJML, most ESPs) nest several layers of these around every actual section. */
|
|
562
|
+
function isPassthroughTable(tb) {
|
|
563
|
+
const trs = tb.querySelectorAll(':scope > tbody > tr, :scope > tr');
|
|
564
|
+
if (trs.length !== 1) return false;
|
|
565
|
+
// A synthetic marker row (see foldLogicWrappers) is a real row, not
|
|
566
|
+
// scaffolding -- passing through it would silently drop the marker that
|
|
567
|
+
// rowsFromContentTable mints from it.
|
|
568
|
+
if (trs[0].getAttribute('data-mc-logic')) return false;
|
|
569
|
+
const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
570
|
+
return cells.length === 1;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function bgOf(el) {
|
|
574
|
+
const st = el.style;
|
|
575
|
+
if (st && st.backgroundColor) return st.backgroundColor;
|
|
576
|
+
// The `background` shorthand is only a usable *color* when it holds no
|
|
577
|
+
// image/gradient -- browsers populate `backgroundColor` from a shorthand
|
|
578
|
+
// that includes a color, so reaching here with a url() means there is no
|
|
579
|
+
// color to take (and taking the raw string set broken values).
|
|
580
|
+
const short = (st && st.background) || '';
|
|
581
|
+
if (short && short.indexOf('url(') < 0 && short.indexOf('gradient(') < 0) return short;
|
|
582
|
+
return (el.getAttribute && el.getAttribute('bgcolor')) || '';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function padOf(el) {
|
|
586
|
+
return el.style ? paddingOf(el.style) : null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** 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`. */
|
|
590
|
+
function looksLikeContainer(el) {
|
|
591
|
+
return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function applyBg(rows, bg) {
|
|
595
|
+
if (bg) rows.forEach((r) => { if (!r.props.bg) r.props.bg = bg; });
|
|
596
|
+
return rows;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Rows built from a genuine content table (`rowsFromContentTable`) already carry real, per-line padding read off their own `<td>`; rows built from a plain buffered run of content (`collectRows`'s `flushBuf`) start at a neutral zero, since there's no source padding to point to at that level. Once one of those zeroed rows bubbles up through a passthrough table or container div that DOES carry padding (the common shape for a single-line MJML/ESP section), that's the closest real signal available, and gets applied -- but only to rows still at zero, so it never overwrites a more specific value a deeper table already set. */
|
|
600
|
+
function applyPad(rows, pad) {
|
|
601
|
+
if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined) setRowPad(r, pad); });
|
|
602
|
+
return rows;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Longhand side colors are always a single value; the `borderColor` shorthand reads back as a per-side list when the sides differ (`rgb(...) rgb(...) currentcolor`), which is not a usable color. */
|
|
606
|
+
function borderColorOf(st) {
|
|
607
|
+
const ok = (v) => (v && v !== 'currentcolor' ? v : '');
|
|
608
|
+
return ok(st.borderTopColor) || ok(st.borderLeftColor) || ok(st.borderRightColor) || ok(st.borderBottomColor) || '';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** One usable CSS border style for models that expose a single style across
|
|
612
|
+
* all enabled edges. Old/imported documents fall back to solid. */
|
|
613
|
+
function borderStyleOf(st) {
|
|
614
|
+
const allowed = new Set(['solid', 'dashed', 'dotted', 'double']);
|
|
615
|
+
const values = [st.borderTopStyle, st.borderRightStyle, st.borderBottomStyle, st.borderLeftStyle, st.borderStyle];
|
|
616
|
+
return values.find((v) => allowed.has(v)) || 'solid';
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** MailCraft rows have one radius for all corners; a per-corner source value (`16px 16px 0 0` on a stacked card) resolves to the largest corner rather than whatever corner happened to be listed first. */
|
|
620
|
+
function radiusOf(st) {
|
|
621
|
+
const parts = String(st.borderRadius || '').split(/\s+/).map(PX);
|
|
622
|
+
return parts.length ? Math.max(0, ...parts) : 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Per-side border widths (the longhands are populated by the `border` shorthand too) plus the overall width -- the largest side, since MailCraft rows have one width shared by every enabled side. */
|
|
626
|
+
function borderSidesOf(st) {
|
|
627
|
+
const sides = {
|
|
628
|
+
top: PX(st.borderTopWidth), right: PX(st.borderRightWidth),
|
|
629
|
+
bottom: PX(st.borderBottomWidth), left: PX(st.borderLeftWidth),
|
|
630
|
+
};
|
|
631
|
+
const width = Math.max(sides.top, sides.right, sides.bottom, sides.left) || PX(st.borderWidth);
|
|
632
|
+
return { width, sides };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function setRowBorder(row, width, sides, color, style) {
|
|
636
|
+
row.props.border = width;
|
|
637
|
+
row.props.borderStyle = style || 'solid';
|
|
638
|
+
row.props.lineColor = color || '#e2e2e5';
|
|
639
|
+
row.props.bTop = sides.top > 0;
|
|
640
|
+
row.props.bRight = sides.right > 0;
|
|
641
|
+
row.props.bBottom = sides.bottom > 0;
|
|
642
|
+
row.props.bLeft = sides.left > 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Border/radius on a passthrough wrapper describe one visual card. With
|
|
647
|
+
* per-side borders the card decomposes faithfully even across several rows:
|
|
648
|
+
* every row keeps the card's left/right edges, the first row alone keeps the
|
|
649
|
+
* top edge, the last alone keeps the bottom -- stacked, they redraw the
|
|
650
|
+
* source's single box with no horizontal lines through the middle. Radius
|
|
651
|
+
* still transfers only to a lone row (MailCraft rounds all four corners, so
|
|
652
|
+
* splitting it across rows would notch the card's sides).
|
|
653
|
+
*/
|
|
654
|
+
function applyFrame(rows, el) {
|
|
655
|
+
if (!rows.length || !el || !el.style) return rows;
|
|
656
|
+
const st = el.style;
|
|
657
|
+
const { width, sides } = borderSidesOf(st);
|
|
658
|
+
if (width) {
|
|
659
|
+
const color = borderColorOf(st);
|
|
660
|
+
rows.forEach((row, i) => {
|
|
661
|
+
if (row.props.border) return;
|
|
662
|
+
setRowBorder(row, width, {
|
|
663
|
+
top: i === 0 ? sides.top : 0,
|
|
664
|
+
bottom: i === rows.length - 1 ? sides.bottom : 0,
|
|
665
|
+
left: sides.left,
|
|
666
|
+
right: sides.right,
|
|
667
|
+
}, color, borderStyleOf(st));
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (rows.length === 1) {
|
|
671
|
+
const radius = radiusOf(st);
|
|
672
|
+
if (radius && !rows[0].props.radius) rows[0].props.radius = radius;
|
|
673
|
+
// Shadow follows the radius rule -- one visual card, one shadow; split
|
|
674
|
+
// across rows it would draw shadows through the card's middle.
|
|
675
|
+
if (st.boxShadow && st.boxShadow !== 'none' && !rows[0].props.shadow) rows[0].props.shadow = st.boxShadow;
|
|
676
|
+
}
|
|
677
|
+
return rows;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function rowsFromContentTable(table) {
|
|
681
|
+
const tableWidthPx = PX(table.getAttribute('width') || table.style.width || '0') || null;
|
|
682
|
+
const trs = Array.from(table.querySelectorAll(':scope > tbody > tr, :scope > tr'));
|
|
683
|
+
return trs.map((tr) => {
|
|
684
|
+
// A synthetic marker row minted by foldLogicWrappers: one dynamic-content
|
|
685
|
+
// marker block, at the exact place the tag held in the source.
|
|
686
|
+
const logicTag = tr.getAttribute('data-mc-logic');
|
|
687
|
+
if (logicTag) {
|
|
688
|
+
const open = logicTag.match(/^#(if|each)\s+(.+)$/);
|
|
689
|
+
const row = mkRow([100]);
|
|
690
|
+
row.props.py = 4; row.props.px = 0; row.props.gap = 0;
|
|
691
|
+
row.cols[0].blocks = [open
|
|
692
|
+
? blk(open[1] === 'if' ? 'condition' : 'loop', { expr: open[2].trim(), end: false })
|
|
693
|
+
: blk(/\/if$/.test(logicTag) ? 'condition' : 'loop', { expr: '', end: true })];
|
|
694
|
+
return row;
|
|
695
|
+
}
|
|
696
|
+
const outerCells = Array.from(tr.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
697
|
+
if (!outerCells.length) return null;
|
|
698
|
+
const bgSource = outerCells[0];
|
|
699
|
+
let cells = outerCells;
|
|
700
|
+
if (outerCells.length === 1) {
|
|
701
|
+
const nested = unwrapNestedLayout(outerCells[0]);
|
|
702
|
+
if (nested) cells = nested;
|
|
703
|
+
}
|
|
704
|
+
// Spacer columns: a content-free `<td>` (often `class="column gap"`,
|
|
705
|
+
// holding only an empty fixed-width table) between real columns exists
|
|
706
|
+
// purely to space them apart. Counted as a column it wrecks the spans
|
|
707
|
+
// (50/50 + gap became thirds); recognized, it becomes the row's `gap`.
|
|
708
|
+
let gapPx = 0;
|
|
709
|
+
if (cells.length > 2) {
|
|
710
|
+
const isGapCell = (td) => {
|
|
711
|
+
if ((td.textContent || '').trim()) return false;
|
|
712
|
+
if (td.querySelector('img,a,input,p,h1,h2,h3,h4,h5,h6,ul,ol')) return false;
|
|
713
|
+
const w = PX(td.getAttribute('width') || (td.style && td.style.width) || '');
|
|
714
|
+
return !w || w <= 30;
|
|
715
|
+
};
|
|
716
|
+
const kept = cells.filter((c) => !isGapCell(c));
|
|
717
|
+
if (kept.length >= 2 && kept.length < cells.length) {
|
|
718
|
+
cells.filter(isGapCell).forEach((g) => {
|
|
719
|
+
const inner = g.querySelector('table');
|
|
720
|
+
const w = PX((inner && (inner.getAttribute('width') || inner.style.width)) || g.getAttribute('width') || '');
|
|
721
|
+
gapPx = Math.max(gapPx, w || 10);
|
|
722
|
+
});
|
|
723
|
+
cells = kept;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Gutter-as-padding: cells that all carry the identical pure-horizontal
|
|
727
|
+
// padding (`0 Xpx` -- exactly what MailCraft's own export writes for a
|
|
728
|
+
// column gap) describe a gap, not row padding. Recognizing it keeps
|
|
729
|
+
// gap -> export -> import a fixed point instead of drifting into padding.
|
|
730
|
+
let gutter = false;
|
|
731
|
+
if (!gapPx && cells.length > 1) {
|
|
732
|
+
const pads = cells.map((c) => paddingOf(c.style));
|
|
733
|
+
const p0 = pads[0];
|
|
734
|
+
gutter = !!(p0 && !p0.t && !p0.b && p0.l > 0 && p0.l === p0.r && p0.l <= 60
|
|
735
|
+
&& pads.every((pp) => pp && !pp.t && !pp.b && pp.l === p0.l && pp.r === p0.r));
|
|
736
|
+
if (gutter) gapPx = p0.l * 2;
|
|
737
|
+
}
|
|
738
|
+
const spans = cells.length === 1 ? [100] : spansFromCells(cells, tableWidthPx);
|
|
739
|
+
const row = mkRow(spans);
|
|
740
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = gapPx;
|
|
741
|
+
// Background and frame can live on the first cell OR on the table itself
|
|
742
|
+
// (builders style `table.row-content`, not its tds) -- read the cell
|
|
743
|
+
// first, the table as fallback. Cell-derived values only count when every
|
|
744
|
+
// cell agrees: a row of differently-colored card columns (pastel feature
|
|
745
|
+
// grids) must not paint the whole row in the first card's color --
|
|
746
|
+
// MailCraft has no per-column background, so those columns' colors are a
|
|
747
|
+
// known loss rather than a wrong repaint.
|
|
748
|
+
// Uniformity is judged among the cells THEMSELVES: after a nested-layout
|
|
749
|
+
// unwrap, `bgSource` is the outer td (whose background legitimately wraps
|
|
750
|
+
// the whole row) while `cells` are the inner columns -- comparing the two
|
|
751
|
+
// wrongly read "non-uniform" and threw the row's own bg/padding away.
|
|
752
|
+
const cellBg = bgOf(cells[0]);
|
|
753
|
+
const cellsUniform = cells.every((c) => bgOf(c) === cellBg);
|
|
754
|
+
// The bgSource fallback is for the nested-unwrap case only, where it's an
|
|
755
|
+
// outer wrapper around all columns. When bgSource IS cells[0] (flat rows),
|
|
756
|
+
// falling back to it would re-promote the first card's color to the row.
|
|
757
|
+
const outerBg = cells.indexOf(bgSource) > -1 ? '' : bgOf(bgSource);
|
|
758
|
+
const bg = (cellsUniform && cellBg) || outerBg || bgOf(table);
|
|
759
|
+
if (bg) row.props.bg = bg;
|
|
760
|
+
// Differently-styled cells become per-column styling: each column keeps
|
|
761
|
+
// its own background/radius/padding (the pastel-cards pattern).
|
|
762
|
+
if (!cellsUniform) {
|
|
763
|
+
cells.forEach((cell, i) => {
|
|
764
|
+
const col = row.cols[i]; if (!col) return;
|
|
765
|
+
const cbg = bgOf(cell); if (cbg) col.bg = cbg;
|
|
766
|
+
const crad = radiusOf(cell.style); if (crad) col.radius = crad;
|
|
767
|
+
const cframe = borderSidesOf(cell.style);
|
|
768
|
+
if (cframe.width) {
|
|
769
|
+
col.border = cframe.width;
|
|
770
|
+
col.borderStyle = borderStyleOf(cell.style);
|
|
771
|
+
col.lineColor = borderColorOf(cell.style) || '#e2e2e5';
|
|
772
|
+
}
|
|
773
|
+
const cpd = paddingOf(cell.style);
|
|
774
|
+
if (cpd) { col.padY = cpd.py; col.padX = cpd.px; }
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
// Read the border per side (the card idiom `border: 1px solid X;
|
|
778
|
+
// border-bottom: none` keeps its exact shape now that rows support
|
|
779
|
+
// per-side toggles), from the cell first, the table as fallback.
|
|
780
|
+
const cellFrame = borderSidesOf(bgSource.style);
|
|
781
|
+
const frameSrc = cellFrame.width ? bgSource.style : table.style;
|
|
782
|
+
const frame = cellFrame.width ? cellFrame : borderSidesOf(table.style);
|
|
783
|
+
if (frame.width) setRowBorder(row, frame.width, frame.sides, borderColorOf(frameSrc), borderStyleOf(frameSrc));
|
|
784
|
+
const radius = (cellsUniform ? radiusOf(bgSource.style) : 0) || radiusOf(table.style);
|
|
785
|
+
if (radius) row.props.radius = radius;
|
|
786
|
+
const shadow = (cellsUniform ? bgSource.style.boxShadow : '') || table.style.boxShadow || '';
|
|
787
|
+
if (shadow && shadow !== 'none') row.props.shadow = shadow;
|
|
788
|
+
const bgiEl = (cellsUniform && bgImageOf(bgSource)) ? bgSource : (bgImageOf(table) ? table : null);
|
|
789
|
+
if (bgiEl) applyBgImage([row], bgiEl);
|
|
790
|
+
// The table itself can carry section padding (Beefree writes
|
|
791
|
+
// `padding-top: 60px` on `.row-content`) on top of the cell's own -- the
|
|
792
|
+
// two nest in the source, so they sum here.
|
|
793
|
+
const tPad = paddingOf(table.style);
|
|
794
|
+
// When cells differ, the first cell's padding just moved onto its own
|
|
795
|
+
// column above -- counting it at row level too would double it up. A
|
|
796
|
+
// detected gutter already became the row gap, so it must not double as
|
|
797
|
+
// padding either -- but only when the gutter actually lives on bgSource
|
|
798
|
+
// (the flat case); after a nested unwrap, bgSource is the outer td whose
|
|
799
|
+
// padding is genuine row padding.
|
|
800
|
+
const gutterOnBgSource = gutter && cells.indexOf(bgSource) > -1;
|
|
801
|
+
const cPad = cellsUniform && !gutterOnBgSource ? paddingOf(bgSource.style) : null;
|
|
802
|
+
if (tPad || cPad) {
|
|
803
|
+
const z = { t: 0, b: 0, l: 0, r: 0 };
|
|
804
|
+
const sum = ['t', 'b', 'l', 'r'].reduce((acc, k) => { acc[k] = ((tPad || z)[k] || 0) + ((cPad || z)[k] || 0); return acc; }, {});
|
|
805
|
+
sum.py = Math.round((sum.t + sum.b) / 2);
|
|
806
|
+
sum.px = Math.round((sum.l + sum.r) / 2);
|
|
807
|
+
setRowPad(row, sum);
|
|
808
|
+
}
|
|
809
|
+
cells.forEach((cell, i) => {
|
|
810
|
+
// A cell whose lone child is a background/radius-carrying div is the
|
|
811
|
+
// "styled column" shape (MailCraft's own export writes it; other
|
|
812
|
+
// builders use it too) -- hoist its styling onto the column and walk
|
|
813
|
+
// its children, or the whole card collapses into one opaque text blob.
|
|
814
|
+
let contentEl = cell;
|
|
815
|
+
const lone = onlyChild(cell, 'DIV');
|
|
816
|
+
if (lone && (bgOf(lone) || radiusOf(lone.style)) && !classifyNode(lone)) {
|
|
817
|
+
const col = row.cols[i];
|
|
818
|
+
if (col) {
|
|
819
|
+
const cbg = bgOf(lone); if (cbg && !col.bg) col.bg = cbg;
|
|
820
|
+
const crad = radiusOf(lone.style); if (crad && !col.radius) col.radius = crad;
|
|
821
|
+
const cpd = paddingOf(lone.style);
|
|
822
|
+
if (cpd && col.padY === undefined) { col.padY = cpd.py; col.padX = cpd.px; }
|
|
823
|
+
}
|
|
824
|
+
contentEl = lone;
|
|
825
|
+
}
|
|
826
|
+
row.cols[i].blocks = blocksFromNodes(Array.from(contentEl.childNodes));
|
|
827
|
+
});
|
|
828
|
+
return row;
|
|
829
|
+
}).filter((r) => r && r.cols.some((c) => c.blocks.length));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Walks a list of sibling nodes and returns the ordered rows they represent.
|
|
834
|
+
* Rather than picking one "best" table for the whole document (which breaks
|
|
835
|
+
* the moment more than one section exists -- everything outside that table
|
|
836
|
+
* silently vanished), this recurses through every layer of transparent
|
|
837
|
+
* scaffolding -- a passthrough table, or a single-child `<div>`/`<center>`
|
|
838
|
+
* wrapper that isn't itself a recognizable block -- to find each genuinely
|
|
839
|
+
* content-bearing table, and expands each one into rows in document order.
|
|
840
|
+
* "Transparent scaffolding" here means a passthrough table (see
|
|
841
|
+
* `isPassthroughTable`) or a `<div>`/`<center>` that nests further
|
|
842
|
+
* divs/tables and isn't itself a recognizable block (see
|
|
843
|
+
* `looksLikeContainer`) -- not just a single-child wrapper, since real
|
|
844
|
+
* builders often nest several section wrappers side by side under one
|
|
845
|
+
* outer page wrapper. Runs of non-table content between/around them become
|
|
846
|
+
* their own single-column rows via `blocksFromNodes`. A wrapper's own
|
|
847
|
+
* background color and padding (common on a per-section `<div>`/table pair)
|
|
848
|
+
* carry onto the rows it produces -- but only where a row doesn't already
|
|
849
|
+
* have a more specific value of its own -- so a colored, padded banner
|
|
850
|
+
* section survives even when it decomposes into several stacked rows,
|
|
851
|
+
* without a deeper row's own real per-line padding getting overwritten by
|
|
852
|
+
* its ancestor's.
|
|
853
|
+
*/
|
|
854
|
+
function collectRows(nodes) {
|
|
855
|
+
const rows = [];
|
|
856
|
+
let buf = [];
|
|
857
|
+
const flushBuf = () => {
|
|
858
|
+
const blocks = blocksFromNodes(buf);
|
|
859
|
+
if (blocks.length) {
|
|
860
|
+
const row = mkRow([100]);
|
|
861
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
862
|
+
row.cols[0].blocks = blocks;
|
|
863
|
+
rows.push(row);
|
|
864
|
+
}
|
|
865
|
+
buf = [];
|
|
866
|
+
};
|
|
867
|
+
nodes.forEach((n) => {
|
|
868
|
+
if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
|
|
869
|
+
if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) return;
|
|
870
|
+
if (n.nodeType === 1 && isHidden(n)) return;
|
|
871
|
+
if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
|
|
872
|
+
flushBuf();
|
|
873
|
+
const inner = collectRows(Array.from(n.childNodes));
|
|
874
|
+
rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (n.nodeType === 1 && n.tagName === 'TABLE') {
|
|
878
|
+
if (n.closest('form,svg')) { buf.push(n); return; } // a form/svg's own table, not page layout -- leave for the html fallback
|
|
879
|
+
// A social-icon table at row level must classify as one block BEFORE
|
|
880
|
+
// the layout machinery sees it -- rowsFromContentTable would otherwise
|
|
881
|
+
// shred it into one column per icon. Only the social classifier runs
|
|
882
|
+
// here: the others (classifyTable especially) would misread genuine
|
|
883
|
+
// layout tables that this walker exists to decompose.
|
|
884
|
+
const social = classifySocial(n);
|
|
885
|
+
if (social) {
|
|
886
|
+
flushBuf();
|
|
887
|
+
const row = mkRow([100]);
|
|
888
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
889
|
+
// The strip's spacing lives on the td holding the icons; the social
|
|
890
|
+
// block itself has no padding props, so it becomes the row's.
|
|
891
|
+
const anchor = n.querySelector('a');
|
|
892
|
+
const holder = anchor && anchor.closest ? anchor.closest('td') : null;
|
|
893
|
+
const pd = holder ? paddingOf(holder.style) : null;
|
|
894
|
+
if (pd) setRowPad(row, pd);
|
|
895
|
+
row.cols[0].blocks = [social];
|
|
896
|
+
rows.push(row);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (isPassthroughTable(n)) {
|
|
900
|
+
const tr = n.querySelector(':scope > tbody > tr, :scope > tr');
|
|
901
|
+
const td = Array.from(tr.children).find((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
902
|
+
flushBuf();
|
|
903
|
+
// The `height`-styled empty cell (a ` ` and nothing else) is the
|
|
904
|
+
// table-email idiom for vertical space -- keep it as a spacer block
|
|
905
|
+
// rather than letting the empty text run evaporate.
|
|
906
|
+
const spacerH = PX(td.style.height);
|
|
907
|
+
if (spacerH && !td.children.length && !(td.textContent || '').replace(/ /g, '').trim()) {
|
|
908
|
+
const row = mkRow([100]);
|
|
909
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
910
|
+
row.cols[0].blocks = [blk('spacer', { height: spacerH })];
|
|
911
|
+
rows.push(row);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const inner = collectRows(Array.from(td.childNodes));
|
|
915
|
+
// Frame styles live on the td for some builders and on the table
|
|
916
|
+
// itself for others (`table.row-content` carries the card border) --
|
|
917
|
+
// applyFrame fills only what's still unset, so trying both is safe.
|
|
918
|
+
rows.push(...applyBgImage(applyBgImage(applyFrame(applyFrame(applyPad(applyBg(inner, bgOf(n) || bgOf(td)), padOf(td) || padOf(n) || cellPadOf(n)), td), n), td), n));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
flushBuf();
|
|
922
|
+
rows.push(...rowsFromContentTable(n));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
buf.push(n);
|
|
926
|
+
});
|
|
927
|
+
flushBuf();
|
|
928
|
+
return rows;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Best-effort document theme, read off the source's own signals: the page
|
|
933
|
+
* background from the body (or the outermost full-width wrapper, where email
|
|
934
|
+
* builders usually put it), the content width as the most common fixed pixel
|
|
935
|
+
* width among layout tables, and the font stack from the first styled element
|
|
936
|
+
* that actually holds text. Without this, every import kept the previous
|
|
937
|
+
* document's theme -- a DM Sans email on #F1F5F9 came back in the default
|
|
938
|
+
* Georgia on the default parchment, which read as "the import broke".
|
|
939
|
+
*/
|
|
940
|
+
function themeFromParsedDoc(doc) {
|
|
941
|
+
const theme = {};
|
|
942
|
+
const body = doc.body;
|
|
943
|
+
let bg = (body.style && (body.style.backgroundColor || body.style.background)) || body.getAttribute('bgcolor') || '';
|
|
944
|
+
if (!bg) {
|
|
945
|
+
const outer = body.querySelector('table');
|
|
946
|
+
if (outer) bg = bgOf(outer) || (outer.querySelector('td') ? bgOf(outer.querySelector('td')) : '');
|
|
947
|
+
}
|
|
948
|
+
if (bg) theme.bg = bg;
|
|
949
|
+
const widthCounts = {};
|
|
950
|
+
body.querySelectorAll('table').forEach((tb) => {
|
|
951
|
+
const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
|
|
952
|
+
if (!w || String(w).endsWith('%')) return;
|
|
953
|
+
const px = PX(w);
|
|
954
|
+
if (px >= 320 && px <= 900) widthCounts[px] = (widthCounts[px] || 0) + 1;
|
|
955
|
+
});
|
|
956
|
+
const bestWidth = Object.keys(widthCounts).sort((a, b) => widthCounts[b] - widthCounts[a])[0];
|
|
957
|
+
if (bestWidth) theme.width = Number(bestWidth);
|
|
958
|
+
// Prefer the first *real* stack (has a comma or quotes) over a lone generic
|
|
959
|
+
// keyword: builders wrap everything in a `font-family:sans-serif` shim div
|
|
960
|
+
// with the actual `'DM Sans', Arial, ...` declared a level deeper.
|
|
961
|
+
const fonts = Array.from(body.querySelectorAll('[style*="font-family"]'))
|
|
962
|
+
.filter((el) => (el.textContent || '').trim())
|
|
963
|
+
.map((el) => el.style.fontFamily)
|
|
964
|
+
.filter(Boolean);
|
|
965
|
+
const font = fonts.find((v) => /[,"']/.test(v)) || fonts[0];
|
|
966
|
+
if (font) theme.font = font;
|
|
967
|
+
// Link color: the most common inline anchor color -- skipping button
|
|
968
|
+
// pills, whose (usually white) label color would otherwise dominate a
|
|
969
|
+
// CTA-heavy email.
|
|
970
|
+
const isPill = (a) => !!((a.style && (a.style.backgroundColor || a.style.background))
|
|
971
|
+
|| Array.from(a.querySelectorAll('span')).some((s) => s.style && (s.style.backgroundColor || s.style.background)));
|
|
972
|
+
const linkCounts = {};
|
|
973
|
+
body.querySelectorAll('a[style*="color"]').forEach((a) => {
|
|
974
|
+
const c = a.style.color;
|
|
975
|
+
// Only text links vote -- icon links (social strips) carry an icon
|
|
976
|
+
// color, not the document's link color.
|
|
977
|
+
if (!c || c === 'inherit' || !(a.textContent || '').trim() || isPill(a)) return;
|
|
978
|
+
linkCounts[c] = (linkCounts[c] || 0) + 1;
|
|
979
|
+
});
|
|
980
|
+
const link = Object.keys(linkCounts).sort((a, b) => linkCounts[b] - linkCounts[a])[0];
|
|
981
|
+
if (link) theme.link = link;
|
|
982
|
+
if (body.style && body.style.color) theme.text = body.style.color;
|
|
983
|
+
return theme;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Dynamic-content tags that sit BETWEEN table rows (what the exporter writes
|
|
988
|
+
* for marker rows, and what hand-written Handlebars emails do around a <tr>)
|
|
989
|
+
* cannot survive DOMParser as-is: stray text inside a <table> is
|
|
990
|
+
* foster-parented out of it, divorcing the tags from their position. So
|
|
991
|
+
* before parsing, each row-adjacent tag becomes a synthetic marker <tr> of
|
|
992
|
+
* its own (`data-mc-logic`), which rowsFromContentTable turns back into a
|
|
993
|
+
* marker-block row at the same place in the document. Tags inside a cell are
|
|
994
|
+
* legal text and are left alone -- `logicMarkersOf` classifies the bare ones
|
|
995
|
+
* into marker blocks, and tags mixed into prose pass through as content,
|
|
996
|
+
* exactly like merge tags. Looped because consecutive tags share adjacency
|
|
997
|
+
* (an {{/each}} right before an {{/if}} only touches a <tr> once the
|
|
998
|
+
* {{/if}} has been converted).
|
|
999
|
+
*/
|
|
1000
|
+
function foldLogicWrappers(src) {
|
|
1001
|
+
let s = String(src);
|
|
1002
|
+
const rowFor = (tag) => '<tr data-mc-logic="' + tag.replace(/"/g, '"') + '"><td></td></tr>';
|
|
1003
|
+
for (let i = 0; i < 6; i++) {
|
|
1004
|
+
const before = s;
|
|
1005
|
+
s = s.replace(/\{\{(#(?:if|each)\s+[^{}]+?|\/(?:if|each))\s*\}\}(?=\s*(?:<tr[\s>]|<\/table))/gi, (m, tag) => rowFor(tag));
|
|
1006
|
+
s = s.replace(/(<\/tr>\s*)\{\{(#(?:if|each)\s+[^{}]+?|\/(?:if|each))\s*\}\}/gi, (m, pre, tag) => pre + rowFor(tag));
|
|
1007
|
+
if (s === before) break;
|
|
1008
|
+
}
|
|
1009
|
+
return s;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Full import entry point: the rows plus the theme patch read from the same source. `theme` only carries keys the source actually declared -- the caller merges it over the current theme so unspecified fields keep their values. */
|
|
1013
|
+
export function htmlToDoc(src) {
|
|
1014
|
+
let doc;
|
|
1015
|
+
try { doc = new DOMParser().parseFromString(foldLogicWrappers(src || ''), 'text/html'); } catch { return { rows: [], theme: {} }; }
|
|
1016
|
+
// Fold <style> rules into inline styles first, so class-styled templates
|
|
1017
|
+
// (never-inlined exports, hand-written emails) classify like inlined ones.
|
|
1018
|
+
// Best-effort: a pathological stylesheet must never block the import.
|
|
1019
|
+
try { inlineStylesheets(doc); } catch { /* proceed with inline styles only */ }
|
|
1020
|
+
return { rows: collectRows(Array.from(doc.body.childNodes)), theme: themeFromParsedDoc(doc) };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export function htmlToRows(src) {
|
|
1024
|
+
return htmlToDoc(src).rows;
|
|
1025
|
+
}
|