@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/binder.js
CHANGED
|
@@ -1,119 +1,121 @@
|
|
|
1
|
-
/** Ported verbatim from the original `binder(getProps, set)` and `decorate(list)`. Pure field-descriptor logic, no DOM. */
|
|
2
|
-
export function binder(getProps, set, core) {
|
|
3
|
-
return {
|
|
4
|
-
head: (label) => ({ kind: 'head', label }),
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
1
|
+
/** Ported verbatim from the original `binder(getProps, set)` and `decorate(list)`. Pure field-descriptor logic, no DOM. */
|
|
2
|
+
export function binder(getProps, set, core) {
|
|
3
|
+
return {
|
|
4
|
+
head: (label) => ({ kind: 'head', label }),
|
|
5
|
+
// `suggestions` (optional array of strings) renders as a datalist on the
|
|
6
|
+
// input: the user picks one of the host's values or types any other.
|
|
7
|
+
text: (label, key, ph, suggestions) => ({ kind: 'text', label, value: getProps()[key] ?? '', placeholder: ph || '', suggestions: Array.isArray(suggestions) && suggestions.length ? suggestions : null, onChange: (v) => set(key, v) }),
|
|
8
|
+
area: (label, key, ph) => ({ kind: 'area', label, value: getProps()[key] ?? '', placeholder: ph || '', onChange: (v) => set(key, v) }),
|
|
9
|
+
// Live typing only commits parseable numbers -- `Number('')` is 0, and
|
|
10
|
+
// committing that the instant a field was cleared to retype (e.g. the
|
|
11
|
+
// theme's content width collapsing to 0px mid-edit) was one of the
|
|
12
|
+
// "inputs behave weird" symptoms. Blur settles the final value, clamped.
|
|
13
|
+
num: (label, key, min, max) => ({
|
|
14
|
+
kind: 'num', label, value: getProps()[key] ?? 0, min, max,
|
|
15
|
+
onChange: (v) => { const n = parseFloat(v); if (Number.isFinite(n)) set(key, n); },
|
|
16
|
+
onBlur: (v) => {
|
|
17
|
+
if (core && core.rendering) return;
|
|
18
|
+
const n = parseFloat(v);
|
|
19
|
+
set(key, Math.min(max, Math.max(min, Number.isFinite(n) ? n : (getProps()[key] ?? min))));
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
color: (label, key) => ({ kind: 'color', label, value: getProps()[key] ?? '', swatch: /^#/.test(getProps()[key] || '') ? getProps()[key] : '#ffffff', onChange: (v) => set(key, v) }),
|
|
23
|
+
// A native range slider commits on every drag tick -- dozens a second --
|
|
24
|
+
// and each one used to trigger a full doc clone + re-render (see
|
|
25
|
+
// `commit`), which is what made dragging feel like stutter instead of a
|
|
26
|
+
// smooth slide. A stepper sidesteps that class of bug entirely: +/- is a
|
|
27
|
+
// single discrete commit per click, and typing a value only commits once
|
|
28
|
+
// you're done (on blur), not per keystroke.
|
|
29
|
+
range: (label, key, min, max, step, unit) => {
|
|
30
|
+
const cur = getProps()[key] ?? min;
|
|
31
|
+
const s = step || 1;
|
|
32
|
+
const decimals = (String(s).split('.')[1] || '').length;
|
|
33
|
+
const clamp = (v) => Math.min(max, Math.max(min, v));
|
|
34
|
+
const round = (v) => Number(v.toFixed(decimals));
|
|
35
|
+
const commit = (v) => set(key, round(clamp(v)));
|
|
36
|
+
return {
|
|
37
|
+
kind: 'range', label, value: cur, min, max, step: s, unit: unit || '', display: cur + (unit || ''),
|
|
38
|
+
onDec: () => commit(cur - s),
|
|
39
|
+
onInc: () => commit(cur + s),
|
|
40
|
+
// Live-typed digits aren't clamped (typing "1" toward "12" with a min
|
|
41
|
+
// of 10 would otherwise get snapped back to 10 mid-keystroke, making
|
|
42
|
+
// the second digit impossible to enter) -- only guarded against a
|
|
43
|
+
// non-numeric intermediate state (e.g. a bare "-").
|
|
44
|
+
onInput: (v) => { const n = parseFloat(v); if (!Number.isNaN(n)) set(key, n); },
|
|
45
|
+
// A render tears down and rebuilds the whole panel subtree (no
|
|
46
|
+
// diffing); removing this still-focused input as part of that forces
|
|
47
|
+
// a synchronous, spurious `blur` before the rebuilt replacement can
|
|
48
|
+
// be refocused. Committing on that blur re-renders again, which
|
|
49
|
+
// tears down and blurs again -- an infinite loop. `core.rendering`
|
|
50
|
+
// (set for the render()'s duration) tells a real blur apart from
|
|
51
|
+
// that artifact -- same guard as canvas.js's RTE `onBlur`.
|
|
52
|
+
onBlur: (v) => { if (core && core.rendering) return; const n = parseFloat(v); commit(Number.isNaN(n) ? cur : n); },
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
// A real drag slider, for coarse visual sizing (content width). The
|
|
56
|
+
// per-tick commit problem that ruled sliders out for ordinary fields
|
|
57
|
+
// (see `range` above) is sidestepped by contract: the renderer moves the
|
|
58
|
+
// value bubble live during the drag but only calls `onCommit` on release
|
|
59
|
+
// ('change'), so a whole drag costs one re-render.
|
|
60
|
+
slider: (label, key, min, max, step, unit) => ({
|
|
61
|
+
kind: 'slider', label, value: getProps()[key] ?? min, min, max, step: step || 1, unit: unit || '',
|
|
62
|
+
onCommit: (v) => { const n = parseFloat(v); if (Number.isFinite(n)) set(key, Math.min(max, Math.max(min, n))); },
|
|
63
|
+
}),
|
|
64
|
+
sel: (label, key, opts) => {
|
|
65
|
+
const options = opts.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
66
|
+
const current = getProps()[key];
|
|
67
|
+
return {
|
|
68
|
+
kind: 'select', label,
|
|
69
|
+
// New controls must not render as an unexplained blank on documents
|
|
70
|
+
// saved before that property existed. The renderer already uses the
|
|
71
|
+
// first option as its semantic fallback; show that same choice here.
|
|
72
|
+
value: current == null ? (options[0]?.value ?? '') : current,
|
|
73
|
+
options,
|
|
74
|
+
onChange: (v) => set(key, v),
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
seg: (label, key, opts) => ({
|
|
78
|
+
kind: 'seg', label, options: opts.map((o) => {
|
|
79
|
+
const v = typeof o === 'string' ? o : o.value; const l = typeof o === 'string' ? o : o.label;
|
|
80
|
+
const on = getProps()[key] === v;
|
|
81
|
+
return { label: l, bg: on ? 'var(--ed-accent)' : 'transparent', fg: on ? 'var(--ed-accent-ink)' : 'var(--ed-muted)', onClick: () => set(key, v) };
|
|
82
|
+
}),
|
|
83
|
+
}),
|
|
84
|
+
tog: (label, key, defaultOn) => {
|
|
85
|
+
const raw = getProps()[key];
|
|
86
|
+
const on = raw === undefined ? !!defaultOn : !!raw;
|
|
87
|
+
return { kind: 'toggle', label, on, onChange: () => set(key, !on) };
|
|
88
|
+
},
|
|
89
|
+
btn: (label, onClick) => ({ kind: 'btn', label, onClick }),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A grid of linked steppers for props that come in sides/corners ("Space
|
|
95
|
+
* inside" top/bottom/left/right). `toggle` ({on, onChange}), when present,
|
|
96
|
+
* renders as the header's "More options" switch that swaps the linked pair
|
|
97
|
+
* for per-side fields; `label: null` drops the header row entirely (the
|
|
98
|
+
* section head above already names the group).
|
|
99
|
+
*/
|
|
100
|
+
export function group(label, items, toggle) {
|
|
101
|
+
return { kind: 'rangeGroup', label, items, toggle: toggle || null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function decorate(list) {
|
|
105
|
+
return list.filter(Boolean).map((f, i) => {
|
|
106
|
+
const d = Object.assign({}, f, {
|
|
107
|
+
key: i,
|
|
108
|
+
isHead: f.kind === 'head', isArea: f.kind === 'area', isBtn: f.kind === 'btn', isSeg: f.kind === 'seg',
|
|
107
109
|
isRange: f.kind === 'range', isToggle: f.kind === 'toggle', isSocial: f.kind === 'social', isTableGrid: f.kind === 'tablegrid', isRichLinks: f.kind === 'richLinks',
|
|
108
|
-
isRangeGroup: f.kind === 'rangeGroup', isSlider: f.kind === 'slider',
|
|
109
|
-
isRow: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
|
|
110
|
-
isField: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
|
|
111
|
-
isText: f.kind === 'text', isNum: f.kind === 'num', isColor: f.kind === 'color', isSelect: f.kind === 'select',
|
|
112
|
-
});
|
|
113
|
-
// Group items are ranges rendered as grid cells: they need decorated
|
|
114
|
-
// flags and focus keys of their own, namespaced under the group's index
|
|
115
|
-
// so they stay unique against the flat list.
|
|
116
|
-
if (d.isRangeGroup) d.items = f.items.map((it, j) => Object.assign({}, it, { key: i + 'g' + j, isRange: true }));
|
|
117
|
-
return d;
|
|
118
|
-
});
|
|
119
|
-
}
|
|
110
|
+
isRangeGroup: f.kind === 'rangeGroup', isSlider: f.kind === 'slider',
|
|
111
|
+
isRow: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
|
|
112
|
+
isField: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
|
|
113
|
+
isText: f.kind === 'text', isNum: f.kind === 'num', isColor: f.kind === 'color', isSelect: f.kind === 'select',
|
|
114
|
+
});
|
|
115
|
+
// Group items are ranges rendered as grid cells: they need decorated
|
|
116
|
+
// flags and focus keys of their own, namespaced under the group's index
|
|
117
|
+
// so they stay unique against the flat list.
|
|
118
|
+
if (d.isRangeGroup) d.items = f.items.map((it, j) => Object.assign({}, it, { key: i + 'g' + j, isRange: true }));
|
|
119
|
+
return d;
|
|
120
|
+
});
|
|
121
|
+
}
|
package/src/core/blocks.js
CHANGED
|
@@ -26,6 +26,19 @@ BLOCKS.push(
|
|
|
26
26
|
{ type: 'box', code: 'BOX', label: 'Section box', hint: 'Styled container with free content', make: () => ({ html: '<strong style="font-size:19px;display:block;margin-bottom:6px">Section title</strong>Drop copy here, or paste markup. The box takes background, padding, border and radius.', bg: '#f8fafc', bgImage: '', border: 1, borderStyle: 'solid', lineColor: '#e2e8f0', topBorder: true, rightBorder: true, bottomBorder: true, leftBorder: true, radius: 12, pad: 22, align: 'left', minH: 0, maxW: 100, shadow: false }) },
|
|
27
27
|
{ type: 'svg', code: 'SVG', label: 'Inline SVG', hint: 'Paste SVG markup', make: () => ({ code: '<svg viewBox="0 0 120 40" width="120" height="40" fill="none" stroke="#0065b3" stroke-width="1.5"><rect x="0.75" y="0.75" width="118.5" height="38.5"/><path d="M12 28l14-16 12 10 10-8 18 14"/></svg>', align: 'left', width: 100, py: 10 }) },
|
|
28
28
|
);
|
|
29
|
+
// Dynamic-content markers. Same contract as merge tags (variables.js): the
|
|
30
|
+
// editor only authors the template tags, never evaluates them -- export emits
|
|
31
|
+
// literal {{#if}}/{{#each}} at the marker's position for the host's engine to
|
|
32
|
+
// run at send time. Each is half of a pair (`end: false` opens, `end: true`
|
|
33
|
+
// closes); insertBlock drops both at once, and the user drags any content --
|
|
34
|
+
// blocks, or whole sections when the markers sit in rows of their own --
|
|
35
|
+
// between them. Export balances the document (core/export.js `logicPlan`), so
|
|
36
|
+
// a stray or missing half degrades to well-formed output rather than a broken
|
|
37
|
+
// template.
|
|
38
|
+
BLOCKS.push(
|
|
39
|
+
{ type: 'condition', code: 'IF', label: 'Condition', hint: 'Show everything between the two markers only when the expression is true', make: () => ({ expr: 'is_premium', end: false }) },
|
|
40
|
+
{ type: 'loop', code: 'EACH', label: 'Loop', hint: 'Repeat everything between the two markers once per list item', make: () => ({ expr: 'order.items', end: false }) },
|
|
41
|
+
);
|
|
29
42
|
|
|
30
43
|
export const DEF = (t) => BLOCKS.find((b) => b.type === t);
|
|
31
44
|
export const mk = (t) => ({ id: uid(), type: t, props: DEF(t).make() });
|
|
@@ -161,7 +174,7 @@ export const PALETTE = [
|
|
|
161
174
|
{ t: 'button' }, { t: 'table' }, { g: 'card' }, { g: 'product' }, { g: 'hero' }, { g: 'stats' },
|
|
162
175
|
{ t: 'box' }, { t: 'divider' }, { t: 'spacer' }, { t: 'social' }, { t: 'video' },
|
|
163
176
|
{ t: 'embed' }, { t: 'menu' }, { g: 'footer' }, { t: 'html' }, { t: 'css' }, { t: 'svg' },
|
|
164
|
-
{ t: 'codeblock' }, { t: 'countdown' },
|
|
177
|
+
{ t: 'codeblock' }, { t: 'countdown' }, { t: 'condition' }, { t: 'loop' },
|
|
165
178
|
];
|
|
166
179
|
|
|
167
180
|
/**
|
package/src/core/css-cascade.js
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Import-time CSS cascade: folds a parsed document's `<style>` rules into the
|
|
3
|
-
* elements' inline styles, so the importer's classifiers (which read inline
|
|
4
|
-
* styles only) see class-styled templates -- Mailchimp exports, hand-written
|
|
5
|
-
* emails, framework output that was never inlined -- the same way a mail
|
|
6
|
-
* client would.
|
|
7
|
-
*
|
|
8
|
-
* Deliberately a subset of a real cascade, matched to what email CSS uses:
|
|
9
|
-
* - `@media` blocks (and every other at-rule) are dropped whole: responsive
|
|
10
|
-
* overrides can't be represented in the imported model, desktop values win.
|
|
11
|
-
* A side benefit: base-rule `.desktop_hide { display:none }` still applies,
|
|
12
|
-
* so mobile-only duplicate content is correctly dropped by the importer's
|
|
13
|
-
* hidden-element skip.
|
|
14
|
-
* - Selectors containing `:` are skipped -- pseudo-classes/-elements are
|
|
15
|
-
* interactive or generated state with no place in a static import.
|
|
16
|
-
* - Specificity is the classic ids/classes/tags count; equal specificity
|
|
17
|
-
* resolves by source order; `!important` wins over everything including
|
|
18
|
-
* inline styles, which otherwise always win (matching browser behavior for
|
|
19
|
-
* the combinations that matter here).
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
function stripComments(css) {
|
|
23
|
-
return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
|
|
27
|
-
function stripAtRules(css) {
|
|
28
|
-
let out = '';
|
|
29
|
-
let i = 0;
|
|
30
|
-
while (i < css.length) {
|
|
31
|
-
if (css[i] === '@') {
|
|
32
|
-
const semi = css.indexOf(';', i);
|
|
33
|
-
const brace = css.indexOf('{', i);
|
|
34
|
-
if (brace === -1 || (semi !== -1 && semi < brace)) {
|
|
35
|
-
i = semi === -1 ? css.length : semi + 1;
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
let depth = 0;
|
|
39
|
-
let j = brace;
|
|
40
|
-
for (; j < css.length; j++) {
|
|
41
|
-
if (css[j] === '{') depth++;
|
|
42
|
-
else if (css[j] === '}') { depth--; if (!depth) break; }
|
|
43
|
-
}
|
|
44
|
-
i = j + 1;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
out += css[i];
|
|
48
|
-
i++;
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function specificity(sel) {
|
|
54
|
-
const ids = (sel.match(/#[\w-]+/g) || []).length;
|
|
55
|
-
const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
|
|
56
|
-
const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
|
|
57
|
-
return ids * 100 + classes * 10 + tags;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function inlineStylesheets(doc) {
|
|
61
|
-
const sheets = Array.from(doc.querySelectorAll('style'));
|
|
62
|
-
if (!sheets.length) return;
|
|
63
|
-
const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
|
|
64
|
-
|
|
65
|
-
const rules = [];
|
|
66
|
-
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
67
|
-
let m;
|
|
68
|
-
let order = 0;
|
|
69
|
-
while ((m = ruleRe.exec(css))) {
|
|
70
|
-
const decls = [];
|
|
71
|
-
m[2].split(';').forEach((d) => {
|
|
72
|
-
const at = d.indexOf(':');
|
|
73
|
-
if (at < 0) return;
|
|
74
|
-
const prop = d.slice(0, at).trim().toLowerCase();
|
|
75
|
-
let value = d.slice(at + 1).trim();
|
|
76
|
-
if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
|
|
77
|
-
const important = /!important\s*$/i.test(value);
|
|
78
|
-
if (important) value = value.replace(/!important\s*$/i, '').trim();
|
|
79
|
-
if (value) decls.push({ prop, value, important });
|
|
80
|
-
});
|
|
81
|
-
if (!decls.length) continue;
|
|
82
|
-
m[1].split(',').forEach((raw) => {
|
|
83
|
-
const sel = raw.trim();
|
|
84
|
-
if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
|
|
85
|
-
rules.push({ sel, decls, spec: specificity(sel), order: order++ });
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
if (!rules.length) return;
|
|
89
|
-
|
|
90
|
-
// Ascending: later (more specific / later-in-source) rules overwrite
|
|
91
|
-
// earlier winners per property below.
|
|
92
|
-
rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
|
|
93
|
-
|
|
94
|
-
const winners = new Map(); // element -> Map(prop -> {value, important})
|
|
95
|
-
rules.forEach((rule) => {
|
|
96
|
-
let matched;
|
|
97
|
-
try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
|
|
98
|
-
matched.forEach((el) => {
|
|
99
|
-
if (el !== doc.body && !doc.body.contains(el)) return;
|
|
100
|
-
let bucket = winners.get(el);
|
|
101
|
-
if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
|
|
102
|
-
rule.decls.forEach((d) => {
|
|
103
|
-
const cur = bucket.get(d.prop);
|
|
104
|
-
if (cur && cur.important && !d.important) return;
|
|
105
|
-
bucket.set(d.prop, d);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
winners.forEach((bucket, el) => {
|
|
111
|
-
if (!el.style) return;
|
|
112
|
-
bucket.forEach((d, prop) => {
|
|
113
|
-
if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
|
|
114
|
-
try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Import-time CSS cascade: folds a parsed document's `<style>` rules into the
|
|
3
|
+
* elements' inline styles, so the importer's classifiers (which read inline
|
|
4
|
+
* styles only) see class-styled templates -- Mailchimp exports, hand-written
|
|
5
|
+
* emails, framework output that was never inlined -- the same way a mail
|
|
6
|
+
* client would.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately a subset of a real cascade, matched to what email CSS uses:
|
|
9
|
+
* - `@media` blocks (and every other at-rule) are dropped whole: responsive
|
|
10
|
+
* overrides can't be represented in the imported model, desktop values win.
|
|
11
|
+
* A side benefit: base-rule `.desktop_hide { display:none }` still applies,
|
|
12
|
+
* so mobile-only duplicate content is correctly dropped by the importer's
|
|
13
|
+
* hidden-element skip.
|
|
14
|
+
* - Selectors containing `:` are skipped -- pseudo-classes/-elements are
|
|
15
|
+
* interactive or generated state with no place in a static import.
|
|
16
|
+
* - Specificity is the classic ids/classes/tags count; equal specificity
|
|
17
|
+
* resolves by source order; `!important` wins over everything including
|
|
18
|
+
* inline styles, which otherwise always win (matching browser behavior for
|
|
19
|
+
* the combinations that matter here).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function stripComments(css) {
|
|
23
|
+
return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
|
|
27
|
+
function stripAtRules(css) {
|
|
28
|
+
let out = '';
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < css.length) {
|
|
31
|
+
if (css[i] === '@') {
|
|
32
|
+
const semi = css.indexOf(';', i);
|
|
33
|
+
const brace = css.indexOf('{', i);
|
|
34
|
+
if (brace === -1 || (semi !== -1 && semi < brace)) {
|
|
35
|
+
i = semi === -1 ? css.length : semi + 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let depth = 0;
|
|
39
|
+
let j = brace;
|
|
40
|
+
for (; j < css.length; j++) {
|
|
41
|
+
if (css[j] === '{') depth++;
|
|
42
|
+
else if (css[j] === '}') { depth--; if (!depth) break; }
|
|
43
|
+
}
|
|
44
|
+
i = j + 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out += css[i];
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function specificity(sel) {
|
|
54
|
+
const ids = (sel.match(/#[\w-]+/g) || []).length;
|
|
55
|
+
const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
|
|
56
|
+
const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
|
|
57
|
+
return ids * 100 + classes * 10 + tags;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function inlineStylesheets(doc) {
|
|
61
|
+
const sheets = Array.from(doc.querySelectorAll('style'));
|
|
62
|
+
if (!sheets.length) return;
|
|
63
|
+
const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
|
|
64
|
+
|
|
65
|
+
const rules = [];
|
|
66
|
+
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
67
|
+
let m;
|
|
68
|
+
let order = 0;
|
|
69
|
+
while ((m = ruleRe.exec(css))) {
|
|
70
|
+
const decls = [];
|
|
71
|
+
m[2].split(';').forEach((d) => {
|
|
72
|
+
const at = d.indexOf(':');
|
|
73
|
+
if (at < 0) return;
|
|
74
|
+
const prop = d.slice(0, at).trim().toLowerCase();
|
|
75
|
+
let value = d.slice(at + 1).trim();
|
|
76
|
+
if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
|
|
77
|
+
const important = /!important\s*$/i.test(value);
|
|
78
|
+
if (important) value = value.replace(/!important\s*$/i, '').trim();
|
|
79
|
+
if (value) decls.push({ prop, value, important });
|
|
80
|
+
});
|
|
81
|
+
if (!decls.length) continue;
|
|
82
|
+
m[1].split(',').forEach((raw) => {
|
|
83
|
+
const sel = raw.trim();
|
|
84
|
+
if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
|
|
85
|
+
rules.push({ sel, decls, spec: specificity(sel), order: order++ });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (!rules.length) return;
|
|
89
|
+
|
|
90
|
+
// Ascending: later (more specific / later-in-source) rules overwrite
|
|
91
|
+
// earlier winners per property below.
|
|
92
|
+
rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
|
|
93
|
+
|
|
94
|
+
const winners = new Map(); // element -> Map(prop -> {value, important})
|
|
95
|
+
rules.forEach((rule) => {
|
|
96
|
+
let matched;
|
|
97
|
+
try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
|
|
98
|
+
matched.forEach((el) => {
|
|
99
|
+
if (el !== doc.body && !doc.body.contains(el)) return;
|
|
100
|
+
let bucket = winners.get(el);
|
|
101
|
+
if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
|
|
102
|
+
rule.decls.forEach((d) => {
|
|
103
|
+
const cur = bucket.get(d.prop);
|
|
104
|
+
if (cur && cur.important && !d.important) return;
|
|
105
|
+
bucket.set(d.prop, d);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
winners.forEach((bucket, el) => {
|
|
111
|
+
if (!el.style) return;
|
|
112
|
+
bucket.forEach((d, prop) => {
|
|
113
|
+
if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
|
|
114
|
+
try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|