@seliseblocks/mailcraft 0.1.1 → 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.
Files changed (69) hide show
  1. package/DOCS.md +238 -10
  2. package/README.md +11 -3
  3. package/README.md.txt +134 -0
  4. package/dist/mailcraft-editor.bundle.js +51 -39
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/order-confirmed.html +80 -80
  7. package/examples/vanilla.html +234 -22
  8. package/package.json +7 -2
  9. package/src/core/assets.js +10 -15
  10. package/src/core/binder.js +120 -118
  11. package/src/core/blocks.js +14 -1
  12. package/src/core/css-cascade.js +117 -117
  13. package/src/core/editor-core.js +1530 -1492
  14. package/src/core/export.js +142 -55
  15. package/src/core/i18n/ar.js +219 -177
  16. package/src/core/i18n/bg.js +196 -152
  17. package/src/core/i18n/bn.js +218 -176
  18. package/src/core/i18n/ca.js +196 -152
  19. package/src/core/i18n/cs.js +196 -152
  20. package/src/core/i18n/da.js +196 -152
  21. package/src/core/i18n/de-CH.js +196 -152
  22. package/src/core/i18n/de.js +196 -152
  23. package/src/core/i18n/dz.js +221 -179
  24. package/src/core/i18n/el.js +196 -152
  25. package/src/core/i18n/en.js +3 -10
  26. package/src/core/i18n/es.js +196 -152
  27. package/src/core/i18n/et.js +196 -152
  28. package/src/core/i18n/fi.js +196 -152
  29. package/src/core/i18n/fr.js +196 -152
  30. package/src/core/i18n/hr.js +196 -152
  31. package/src/core/i18n/hu.js +196 -152
  32. package/src/core/i18n/index.js +83 -83
  33. package/src/core/i18n/it.js +196 -152
  34. package/src/core/i18n/lt.js +196 -152
  35. package/src/core/i18n/lv.js +196 -152
  36. package/src/core/i18n/nb.js +196 -152
  37. package/src/core/i18n/nl.js +196 -152
  38. package/src/core/i18n/pl.js +196 -152
  39. package/src/core/i18n/pt.js +196 -152
  40. package/src/core/i18n/ro.js +196 -152
  41. package/src/core/i18n/ru.js +196 -152
  42. package/src/core/i18n/sk.js +196 -152
  43. package/src/core/i18n/sl.js +196 -152
  44. package/src/core/i18n/sv.js +196 -152
  45. package/src/core/i18n/tables.js +50 -50
  46. package/src/core/i18n/tr.js +196 -152
  47. package/src/core/i18n/uk.js +196 -152
  48. package/src/core/icons.js +237 -235
  49. package/src/core/ids.js +1 -1
  50. package/src/core/import-html.js +1025 -959
  51. package/src/core/layout-style.js +100 -100
  52. package/src/core/parse.js +10 -10
  53. package/src/core/placeholder.js +15 -15
  54. package/src/core/sanitize.js +141 -141
  55. package/src/core/storage-limits.js +184 -184
  56. package/src/core/storage.js +85 -85
  57. package/src/core/theme.js +1 -1
  58. package/src/core/variables.js +11 -11
  59. package/src/index.js +9 -9
  60. package/src/mailcraft-editor.js +26 -14
  61. package/src/render/block-body.js +49 -6
  62. package/src/render/canvas.js +31 -2
  63. package/src/render/fields.js +602 -588
  64. package/src/render/focus-preserve.js +158 -158
  65. package/src/render/rte.js +241 -212
  66. package/src/render/screenshot.js +132 -132
  67. package/src/render/story.js +415 -415
  68. package/src/render/style.js +8 -0
  69. package/types/index.d.ts +419 -0
@@ -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
- text: (label, key, ph) => ({ kind: 'text', label, value: getProps()[key] ?? '', placeholder: ph || '', onChange: (v) => set(key, v) }),
6
- area: (label, key, ph) => ({ kind: 'area', label, value: getProps()[key] ?? '', placeholder: ph || '', onChange: (v) => set(key, v) }),
7
- // Live typing only commits parseable numbers -- `Number('')` is 0, and
8
- // committing that the instant a field was cleared to retype (e.g. the
9
- // theme's content width collapsing to 0px mid-edit) was one of the
10
- // "inputs behave weird" symptoms. Blur settles the final value, clamped.
11
- num: (label, key, min, max) => ({
12
- kind: 'num', label, value: getProps()[key] ?? 0, min, max,
13
- onChange: (v) => { const n = parseFloat(v); if (Number.isFinite(n)) set(key, n); },
14
- onBlur: (v) => {
15
- if (core && core.rendering) return;
16
- const n = parseFloat(v);
17
- set(key, Math.min(max, Math.max(min, Number.isFinite(n) ? n : (getProps()[key] ?? min))));
18
- },
19
- }),
20
- color: (label, key) => ({ kind: 'color', label, value: getProps()[key] ?? '', swatch: /^#/.test(getProps()[key] || '') ? getProps()[key] : '#ffffff', onChange: (v) => set(key, v) }),
21
- // A native range slider commits on every drag tick -- dozens a second --
22
- // and each one used to trigger a full doc clone + re-render (see
23
- // `commit`), which is what made dragging feel like stutter instead of a
24
- // smooth slide. A stepper sidesteps that class of bug entirely: +/- is a
25
- // single discrete commit per click, and typing a value only commits once
26
- // you're done (on blur), not per keystroke.
27
- range: (label, key, min, max, step, unit) => {
28
- const cur = getProps()[key] ?? min;
29
- const s = step || 1;
30
- const decimals = (String(s).split('.')[1] || '').length;
31
- const clamp = (v) => Math.min(max, Math.max(min, v));
32
- const round = (v) => Number(v.toFixed(decimals));
33
- const commit = (v) => set(key, round(clamp(v)));
34
- return {
35
- kind: 'range', label, value: cur, min, max, step: s, unit: unit || '', display: cur + (unit || ''),
36
- onDec: () => commit(cur - s),
37
- onInc: () => commit(cur + s),
38
- // Live-typed digits aren't clamped (typing "1" toward "12" with a min
39
- // of 10 would otherwise get snapped back to 10 mid-keystroke, making
40
- // the second digit impossible to enter) -- only guarded against a
41
- // non-numeric intermediate state (e.g. a bare "-").
42
- onInput: (v) => { const n = parseFloat(v); if (!Number.isNaN(n)) set(key, n); },
43
- // A render tears down and rebuilds the whole panel subtree (no
44
- // diffing); removing this still-focused input as part of that forces
45
- // a synchronous, spurious `blur` before the rebuilt replacement can
46
- // be refocused. Committing on that blur re-renders again, which
47
- // tears down and blurs again -- an infinite loop. `core.rendering`
48
- // (set for the render()'s duration) tells a real blur apart from
49
- // that artifact -- same guard as canvas.js's RTE `onBlur`.
50
- onBlur: (v) => { if (core && core.rendering) return; const n = parseFloat(v); commit(Number.isNaN(n) ? cur : n); },
51
- };
52
- },
53
- // A real drag slider, for coarse visual sizing (content width). The
54
- // per-tick commit problem that ruled sliders out for ordinary fields
55
- // (see `range` above) is sidestepped by contract: the renderer moves the
56
- // value bubble live during the drag but only calls `onCommit` on release
57
- // ('change'), so a whole drag costs one re-render.
58
- slider: (label, key, min, max, step, unit) => ({
59
- kind: 'slider', label, value: getProps()[key] ?? min, min, max, step: step || 1, unit: unit || '',
60
- onCommit: (v) => { const n = parseFloat(v); if (Number.isFinite(n)) set(key, Math.min(max, Math.max(min, n))); },
61
- }),
62
- sel: (label, key, opts) => {
63
- const options = opts.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
64
- const current = getProps()[key];
65
- return {
66
- kind: 'select', label,
67
- // New controls must not render as an unexplained blank on documents
68
- // saved before that property existed. The renderer already uses the
69
- // first option as its semantic fallback; show that same choice here.
70
- value: current == null ? (options[0]?.value ?? '') : current,
71
- options,
72
- onChange: (v) => set(key, v),
73
- };
74
- },
75
- seg: (label, key, opts) => ({
76
- kind: 'seg', label, options: opts.map((o) => {
77
- const v = typeof o === 'string' ? o : o.value; const l = typeof o === 'string' ? o : o.label;
78
- const on = getProps()[key] === v;
79
- return { label: l, bg: on ? 'var(--ed-accent)' : 'transparent', fg: on ? 'var(--ed-accent-ink)' : 'var(--ed-muted)', onClick: () => set(key, v) };
80
- }),
81
- }),
82
- tog: (label, key, defaultOn) => {
83
- const raw = getProps()[key];
84
- const on = raw === undefined ? !!defaultOn : !!raw;
85
- return { kind: 'toggle', label, on, onChange: () => set(key, !on) };
86
- },
87
- btn: (label, onClick) => ({ kind: 'btn', label, onClick }),
88
- };
89
- }
90
-
91
- /**
92
- * A grid of linked steppers for props that come in sides/corners ("Space
93
- * inside" top/bottom/left/right). `toggle` ({on, onChange}), when present,
94
- * renders as the header's "More options" switch that swaps the linked pair
95
- * for per-side fields; `label: null` drops the header row entirely (the
96
- * section head above already names the group).
97
- */
98
- export function group(label, items, toggle) {
99
- return { kind: 'rangeGroup', label, items, toggle: toggle || null };
100
- }
101
-
102
- export function decorate(list) {
103
- return list.filter(Boolean).map((f, i) => {
104
- const d = Object.assign({}, f, {
105
- key: i,
106
- isHead: f.kind === 'head', isArea: f.kind === 'area', isBtn: f.kind === 'btn', isSeg: f.kind === 'seg',
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
+ }
@@ -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
  /**
@@ -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
+ }