@seliseblocks/mailcraft 0.1.1 → 0.2.1

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 (86) hide show
  1. package/DOCS.md +243 -16
  2. package/README.md +15 -6
  3. package/README.md.txt +135 -0
  4. package/dist/mailcraft-editor.bundle.js +87 -47
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/activate-your-account.html +1 -1
  7. package/examples/templates/back-in-stock.html +1 -1
  8. package/examples/templates/cart-left-behind.html +1 -1
  9. package/examples/templates/community-giveaway.html +1 -1
  10. package/examples/templates/frontend-futures-invite.html +1 -1
  11. package/examples/templates/give-25-get-25.html +1 -1
  12. package/examples/templates/invoice-paid.html +1 -1
  13. package/examples/templates/meet-nova-launch.html +1 -1
  14. package/examples/templates/mega-weekend-sale.html +1 -1
  15. package/examples/templates/order-confirmed.html +80 -80
  16. package/examples/templates/rate-your-headphones.html +1 -1
  17. package/examples/templates/reset-your-password.html +1 -1
  18. package/examples/templates/thankyou-promo-code.html +1 -1
  19. package/examples/templates/the-sunday-brief.html +1 -1
  20. package/examples/templates/welcome-to-your-workspace.html +1 -1
  21. package/examples/templates/your-order-shipped.html +1 -1
  22. package/examples/templates/your-password-was-changed.html +1 -1
  23. package/examples/templates/your-signin-code.html +1 -1
  24. package/examples/vanilla.html +261 -47
  25. package/package.json +7 -2
  26. package/src/core/assets.js +10 -15
  27. package/src/core/binder.js +140 -118
  28. package/src/core/blocks.js +14 -1
  29. package/src/core/css-cascade.js +117 -117
  30. package/src/core/editor-core.js +1540 -1492
  31. package/src/core/export.js +158 -55
  32. package/src/core/i18n/ar.js +218 -177
  33. package/src/core/i18n/bg.js +195 -152
  34. package/src/core/i18n/bn.js +217 -176
  35. package/src/core/i18n/ca.js +195 -152
  36. package/src/core/i18n/cs.js +195 -152
  37. package/src/core/i18n/da.js +195 -152
  38. package/src/core/i18n/de-CH.js +195 -152
  39. package/src/core/i18n/de.js +195 -152
  40. package/src/core/i18n/dz.js +220 -179
  41. package/src/core/i18n/el.js +195 -152
  42. package/src/core/i18n/en.js +3 -11
  43. package/src/core/i18n/es.js +195 -152
  44. package/src/core/i18n/et.js +195 -152
  45. package/src/core/i18n/fi.js +195 -152
  46. package/src/core/i18n/fr.js +195 -152
  47. package/src/core/i18n/hr.js +195 -152
  48. package/src/core/i18n/hu.js +195 -152
  49. package/src/core/i18n/index.js +83 -83
  50. package/src/core/i18n/it.js +195 -152
  51. package/src/core/i18n/lt.js +195 -152
  52. package/src/core/i18n/lv.js +195 -152
  53. package/src/core/i18n/nb.js +195 -152
  54. package/src/core/i18n/nl.js +195 -152
  55. package/src/core/i18n/pl.js +195 -152
  56. package/src/core/i18n/pt.js +195 -152
  57. package/src/core/i18n/ro.js +195 -152
  58. package/src/core/i18n/ru.js +195 -152
  59. package/src/core/i18n/sk.js +195 -152
  60. package/src/core/i18n/sl.js +195 -152
  61. package/src/core/i18n/sv.js +195 -152
  62. package/src/core/i18n/tables.js +50 -50
  63. package/src/core/i18n/tr.js +195 -152
  64. package/src/core/i18n/uk.js +195 -152
  65. package/src/core/icons.js +237 -235
  66. package/src/core/ids.js +1 -1
  67. package/src/core/import-html.js +1047 -959
  68. package/src/core/layout-style.js +100 -100
  69. package/src/core/parse.js +10 -10
  70. package/src/core/placeholder.js +15 -15
  71. package/src/core/sanitize.js +141 -141
  72. package/src/core/storage-limits.js +189 -184
  73. package/src/core/storage.js +85 -85
  74. package/src/core/theme.js +15 -1
  75. package/src/core/variables.js +11 -11
  76. package/src/index.js +9 -9
  77. package/src/mailcraft-editor.js +64 -25
  78. package/src/render/block-body.js +49 -6
  79. package/src/render/canvas.js +74 -8
  80. package/src/render/fields.js +619 -588
  81. package/src/render/focus-preserve.js +158 -158
  82. package/src/render/rte.js +241 -212
  83. package/src/render/screenshot.js +138 -132
  84. package/src/render/story.js +415 -415
  85. package/src/render/style.js +44 -8
  86. package/types/index.d.ts +422 -0
@@ -1,119 +1,141 @@
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
+ /*
23
+ * `opts.transparent` adds a "no fill" switch beside the swatch. The hex
24
+ * box always accepted any CSS colour word -- typing `transparent` into it
25
+ * already worked -- but nothing said so, and a native colour input cannot
26
+ * express "none", so the one value a page or content background most
27
+ * often wants was effectively unreachable. `opts.solid` is what the
28
+ * switch restores when it is turned back off.
29
+ */
30
+ color: (label, key, opts) => {
31
+ const raw = getProps()[key] ?? '';
32
+ const v = String(raw).trim();
33
+ const transparent = /^(transparent|none)$/i.test(v) || /^rgba\([^)]*,\s*0*(?:\.0+)?\s*\)$/i.test(v);
34
+ return {
35
+ kind: 'color', label, value: raw,
36
+ swatch: /^#/.test(raw || '') ? raw : '#ffffff',
37
+ transparent,
38
+ clearable: !!(opts && opts.transparent),
39
+ onChange: (x) => set(key, x),
40
+ onToggleTransparent: () => set(key, transparent ? ((opts && opts.solid) || '#ffffff') : 'transparent'),
41
+ };
42
+ },
43
+ // A native range slider commits on every drag tick -- dozens a second --
44
+ // and each one used to trigger a full doc clone + re-render (see
45
+ // `commit`), which is what made dragging feel like stutter instead of a
46
+ // smooth slide. A stepper sidesteps that class of bug entirely: +/- is a
47
+ // single discrete commit per click, and typing a value only commits once
48
+ // you're done (on blur), not per keystroke.
49
+ range: (label, key, min, max, step, unit) => {
50
+ const cur = getProps()[key] ?? min;
51
+ const s = step || 1;
52
+ const decimals = (String(s).split('.')[1] || '').length;
53
+ const clamp = (v) => Math.min(max, Math.max(min, v));
54
+ const round = (v) => Number(v.toFixed(decimals));
55
+ const commit = (v) => set(key, round(clamp(v)));
56
+ return {
57
+ kind: 'range', label, value: cur, min, max, step: s, unit: unit || '', display: cur + (unit || ''),
58
+ onDec: () => commit(cur - s),
59
+ onInc: () => commit(cur + s),
60
+ // Live-typed digits aren't clamped (typing "1" toward "12" with a min
61
+ // of 10 would otherwise get snapped back to 10 mid-keystroke, making
62
+ // the second digit impossible to enter) -- only guarded against a
63
+ // non-numeric intermediate state (e.g. a bare "-").
64
+ onInput: (v) => { const n = parseFloat(v); if (!Number.isNaN(n)) set(key, n); },
65
+ // A render tears down and rebuilds the whole panel subtree (no
66
+ // diffing); removing this still-focused input as part of that forces
67
+ // a synchronous, spurious `blur` before the rebuilt replacement can
68
+ // be refocused. Committing on that blur re-renders again, which
69
+ // tears down and blurs again -- an infinite loop. `core.rendering`
70
+ // (set for the render()'s duration) tells a real blur apart from
71
+ // that artifact -- same guard as canvas.js's RTE `onBlur`.
72
+ onBlur: (v) => { if (core && core.rendering) return; const n = parseFloat(v); commit(Number.isNaN(n) ? cur : n); },
73
+ };
74
+ },
75
+ // A real drag slider, for coarse visual sizing (content width). The
76
+ // per-tick commit problem that ruled sliders out for ordinary fields
77
+ // (see `range` above) is sidestepped by contract: the renderer moves the
78
+ // value bubble live during the drag but only calls `onCommit` on release
79
+ // ('change'), so a whole drag costs one re-render.
80
+ slider: (label, key, min, max, step, unit) => ({
81
+ kind: 'slider', label, value: getProps()[key] ?? min, min, max, step: step || 1, unit: unit || '',
82
+ onCommit: (v) => { const n = parseFloat(v); if (Number.isFinite(n)) set(key, Math.min(max, Math.max(min, n))); },
83
+ }),
84
+ sel: (label, key, opts) => {
85
+ const options = opts.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
86
+ const current = getProps()[key];
87
+ return {
88
+ kind: 'select', label,
89
+ // New controls must not render as an unexplained blank on documents
90
+ // saved before that property existed. The renderer already uses the
91
+ // first option as its semantic fallback; show that same choice here.
92
+ value: current == null ? (options[0]?.value ?? '') : current,
93
+ options,
94
+ onChange: (v) => set(key, v),
95
+ };
96
+ },
97
+ seg: (label, key, opts) => ({
98
+ kind: 'seg', label, options: opts.map((o) => {
99
+ const v = typeof o === 'string' ? o : o.value; const l = typeof o === 'string' ? o : o.label;
100
+ const on = getProps()[key] === v;
101
+ return { label: l, bg: on ? 'var(--ed-accent)' : 'transparent', fg: on ? 'var(--ed-accent-ink)' : 'var(--ed-muted)', onClick: () => set(key, v) };
102
+ }),
103
+ }),
104
+ tog: (label, key, defaultOn) => {
105
+ const raw = getProps()[key];
106
+ const on = raw === undefined ? !!defaultOn : !!raw;
107
+ return { kind: 'toggle', label, on, onChange: () => set(key, !on) };
108
+ },
109
+ btn: (label, onClick) => ({ kind: 'btn', label, onClick }),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * A grid of linked steppers for props that come in sides/corners ("Space
115
+ * inside" top/bottom/left/right). `toggle` ({on, onChange}), when present,
116
+ * renders as the header's "More options" switch that swaps the linked pair
117
+ * for per-side fields; `label: null` drops the header row entirely (the
118
+ * section head above already names the group).
119
+ */
120
+ export function group(label, items, toggle) {
121
+ return { kind: 'rangeGroup', label, items, toggle: toggle || null };
122
+ }
123
+
124
+ export function decorate(list) {
125
+ return list.filter(Boolean).map((f, i) => {
126
+ const d = Object.assign({}, f, {
127
+ key: i,
128
+ isHead: f.kind === 'head', isArea: f.kind === 'area', isBtn: f.kind === 'btn', isSeg: f.kind === 'seg',
107
129
  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
- }
130
+ isRangeGroup: f.kind === 'rangeGroup', isSlider: f.kind === 'slider',
131
+ isRow: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
132
+ isField: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
133
+ isText: f.kind === 'text', isNum: f.kind === 'num', isColor: f.kind === 'color', isSelect: f.kind === 'select',
134
+ });
135
+ // Group items are ranges rendered as grid cells: they need decorated
136
+ // flags and focus keys of their own, namespaced under the group's index
137
+ // so they stay unique against the flat list.
138
+ if (d.isRangeGroup) d.items = f.items.map((it, j) => Object.assign({}, it, { key: i + 'g' + j, isRange: true }));
139
+ return d;
140
+ });
141
+ }
@@ -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
+ }