@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.
Files changed (69) hide show
  1. package/DOCS.md +240 -12
  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 +242 -23
  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,46 +1,46 @@
1
- /**
2
- * Pure style-computation functions shared by the live renderer and the export
3
- * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
- * directly via `Object.assign(el.style, obj)`.
5
- */
6
-
7
- import { cssUrl } from './sanitize.js';
8
- export function pad(p) {
9
- return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
- }
11
-
12
- export function boxStyle(p) {
13
- const on = (key) => p[key] !== false;
14
- const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
- return {
16
- background: p.bBg || 'transparent',
17
- borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
- borderRadius: (p.bRadius || 0) + 'px',
19
- padding: (p.bPad || 0) + 'px',
20
- };
21
- }
22
-
23
- export function boxCss(p) {
24
- const bits = [];
25
- if (p.bBg) bits.push('background:' + p.bBg);
26
- if (p.bBorder) {
27
- const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
- const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
- if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
- else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
- }
32
- if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
- if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
- return bits.length ? bits.join(';') + ';' : 'margin:0';
35
- }
36
-
37
- /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
1
+ /**
2
+ * Pure style-computation functions shared by the live renderer and the export
3
+ * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
+ * directly via `Object.assign(el.style, obj)`.
5
+ */
6
+
7
+ import { cssUrl } from './sanitize.js';
8
+ export function pad(p) {
9
+ return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
+ }
11
+
12
+ export function boxStyle(p) {
13
+ const on = (key) => p[key] !== false;
14
+ const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
+ return {
16
+ background: p.bBg || 'transparent',
17
+ borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
+ borderRadius: (p.bRadius || 0) + 'px',
19
+ padding: (p.bPad || 0) + 'px',
20
+ };
21
+ }
22
+
23
+ export function boxCss(p) {
24
+ const bits = [];
25
+ if (p.bBg) bits.push('background:' + p.bBg);
26
+ if (p.bBorder) {
27
+ const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
+ const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
+ if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
+ else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
+ }
32
+ if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
+ if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
+ return bits.length ? bits.join(';') + ';' : 'margin:0';
35
+ }
36
+
37
+ /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
38
38
  export function rowPad(p) {
39
- const t = p.pt ?? p.py ?? 0;
40
- const b = p.pb ?? p.py ?? 0;
41
- const l = p.pl ?? p.px ?? 0;
42
- const r = p.pr ?? p.px ?? 0;
43
- return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
39
+ const t = p.pt ?? p.py ?? 0;
40
+ const b = p.pb ?? p.py ?? 0;
41
+ const l = p.pl ?? p.px ?? 0;
42
+ const r = p.pr ?? p.px ?? 0;
43
+ return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
44
44
  }
45
45
 
46
46
  /** A row's outside spacing in CSS clockwise order, with the old vertical `my` value as a saved-document fallback. Empty horizontal margins can remain `auto` in the live canvas so the advanced max-width control stays centered. */
@@ -52,64 +52,64 @@ export function rowMargin(p, centerEmpty) {
52
52
  const emptyHorizontal = centerEmpty && !r && !l;
53
53
  return t + 'px ' + (emptyHorizontal ? 'auto' : r + 'px') + ' ' + b + 'px ' + (emptyHorizontal ? 'auto' : l + 'px');
54
54
  }
55
-
56
- /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
- export function rowBorderSides(p) {
58
- return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
- }
60
-
61
- /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
- export function rowBorderCss(p) {
63
- if (!p.border) return '';
64
- const s = rowBorderSides(p);
65
- const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
- if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
- return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
- }
69
-
70
- export function rowBg(p) {
71
- const ov = (p.overlay || 0) / 100;
72
- const layers = [];
73
- if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
- if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
- const s = rowBorderSides(p);
76
- const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
- return {
78
- backgroundColor: p.bg || 'transparent',
79
- backgroundImage: layers.length ? layers.join(',') : 'none',
80
- backgroundSize: p.bgSize || 'cover',
81
- backgroundPosition: p.bgPos || 'center',
82
- backgroundRepeat: p.bgRepeat || 'no-repeat',
83
- borderTop: side(s.top),
84
- borderRight: side(s.right),
85
- borderBottom: side(s.bottom),
86
- borderLeft: side(s.left),
87
- borderRadius: (p.radius || 0) + 'px',
88
- // A raw CSS string, not a boolean: imports keep the source's exact
89
- // shadow; the inspector toggle writes/clears a standard one.
90
- boxShadow: p.shadow || 'none',
91
- maxWidth: (p.maxW || 100) + '%',
55
+
56
+ /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
+ export function rowBorderSides(p) {
58
+ return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
+ }
60
+
61
+ /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
+ export function rowBorderCss(p) {
63
+ if (!p.border) return '';
64
+ const s = rowBorderSides(p);
65
+ const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
+ if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
+ return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
+ }
69
+
70
+ export function rowBg(p) {
71
+ const ov = (p.overlay || 0) / 100;
72
+ const layers = [];
73
+ if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
+ if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
+ const s = rowBorderSides(p);
76
+ const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
+ return {
78
+ backgroundColor: p.bg || 'transparent',
79
+ backgroundImage: layers.length ? layers.join(',') : 'none',
80
+ backgroundSize: p.bgSize || 'cover',
81
+ backgroundPosition: p.bgPos || 'center',
82
+ backgroundRepeat: p.bgRepeat || 'no-repeat',
83
+ borderTop: side(s.top),
84
+ borderRight: side(s.right),
85
+ borderBottom: side(s.bottom),
86
+ borderLeft: side(s.left),
87
+ borderRadius: (p.radius || 0) + 'px',
88
+ // A raw CSS string, not a boolean: imports keep the source's exact
89
+ // shadow; the inspector toggle writes/clears a standard one.
90
+ boxShadow: p.shadow || 'none',
91
+ maxWidth: (p.maxW || 100) + '%',
92
92
  margin: rowMargin(p, true),
93
93
  };
94
94
  }
95
-
96
- export function colsWrap(p) {
97
- const gap = p.gap || 0;
98
- if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
- if (p.layout === 'flex') {
100
- return {
101
- display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
- alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
- };
104
- }
105
- return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
- }
107
-
108
- export function colStyle(p, c) {
109
- if (p.layout === 'grid') return { minWidth: 0 };
110
- if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
- return {
112
- flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
- alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
- };
115
- }
95
+
96
+ export function colsWrap(p) {
97
+ const gap = p.gap || 0;
98
+ if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
+ if (p.layout === 'flex') {
100
+ return {
101
+ display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
+ alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
+ };
104
+ }
105
+ return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
+ }
107
+
108
+ export function colStyle(p, c) {
109
+ if (p.layout === 'grid') return { minWidth: 0 };
110
+ if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
+ return {
112
+ flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
+ alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
+ };
115
+ }
package/src/core/parse.js CHANGED
@@ -1,10 +1,10 @@
1
- export function parseItems(s) {
2
- return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
- const i = l.indexOf('|');
4
- return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
- });
6
- }
7
-
8
- export function cellsOf(p) {
9
- return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
- }
1
+ export function parseItems(s) {
2
+ return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
+ const i = l.indexOf('|');
4
+ return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
+ });
6
+ }
7
+
8
+ export function cellsOf(p) {
9
+ return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
+ }
@@ -1,15 +1,15 @@
1
- /** Data-URI placeholder image generator, ported verbatim from the original. */
2
- function enc(s) {
3
- return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
- }
5
-
6
- export function PH(label, w, ht) {
7
- return 'data:image/svg+xml;utf8,' + enc(
8
- '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
- '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
- '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
- '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
- '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
- '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
- );
15
- }
1
+ /** Data-URI placeholder image generator, ported verbatim from the original. */
2
+ function enc(s) {
3
+ return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
+ }
5
+
6
+ export function PH(label, w, ht) {
7
+ return 'data:image/svg+xml;utf8,' + enc(
8
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
+ '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
+ '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
+ '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
+ '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
+ '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
+ );
15
+ }
@@ -1,141 +1,141 @@
1
- /** Scopes authored CSS to the email sheet so a raw block can never restyle the editor chrome. Ported verbatim: manual brace-matching, not the CSSOM, so arbitrary/partial CSS still scopes instead of failing to parse. */
2
- export const scopeCss = (css, root) => {
3
- const src = String(css || '');
4
- let out = ''; let i = 0;
5
- const prefix = (sel) => sel.split(',').map((s) => {
6
- const t = s.trim();
7
- if (!t) return '';
8
- if (/^(from|to|\d+%)$/i.test(t)) return t;
9
- if (t.startsWith('@')) return t;
10
- if (/^(html|body|:root)\b/i.test(t)) return root + t.replace(/^(html|body|:root)/i, '');
11
- return root + ' ' + t;
12
- }).filter(Boolean).join(', ');
13
- while (i < src.length) {
14
- const brace = src.indexOf('{', i);
15
- if (brace < 0) { out += src.slice(i); break; }
16
- const head = src.slice(i, brace).trim();
17
- let depth = 1; let j = brace + 1;
18
- while (j < src.length && depth > 0) { if (src[j] === '{') depth++; else if (src[j] === '}') depth--; j++; }
19
- const body = src.slice(brace + 1, j - 1);
20
- if (head.startsWith('@')) {
21
- out += head + '{' + (/^@(media|supports|layer|container)/i.test(head) ? scopeCss(body, root) : body) + '}';
22
- } else {
23
- out += prefix(head) + '{' + body + '}';
24
- }
25
- i = j;
26
- }
27
- return out;
28
- };
29
-
30
- export const migrateTokens = (json) => String(json).replace(/\[\[\s*([\w.]+)\s*\]\]/g, '{' + '{ $1 }' + '}');
31
-
32
- /** Paste sanitizer -- Word/Docs/Notion drop class soup, mso- properties and nested spans into the document; keep a small tag whitelist and drop attributes (href/target/rel on links survive). */
33
- const PASTE_OK = { A: 1, B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, STRIKE: 1, BR: 1, P: 1, UL: 1, OL: 1, LI: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, BLOCKQUOTE: 1, CODE: 1, SUP: 1, SUB: 1 };
34
-
35
- export const cleanHtml = (html) => {
36
- const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
37
- doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
38
- const walk = (node) => {
39
- Array.from(node.children).forEach((el) => {
40
- walk(el);
41
- if (!PASTE_OK[el.tagName]) {
42
- const parent = el.parentNode;
43
- while (el.firstChild) parent.insertBefore(el.firstChild, el);
44
- parent.removeChild(el);
45
- return;
46
- }
47
- Array.from(el.attributes).forEach((at) => {
48
- const keep = el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1;
49
- if (!keep) el.removeAttribute(at.name);
50
- });
51
- });
52
- };
53
- walk(doc.body);
54
- return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
55
- };
56
-
57
- /**
58
- * Import sanitizer. Pasting wants `cleanHtml`'s scorched-earth policy (Word/
59
- * Docs class soup adds nothing), but an HTML *import* is the opposite case:
60
- * a hand-built email's inline styles ARE the design. Running imports through
61
- * the paste path collapsed every distinctly-styled paragraph -- a 26px/800
62
- * headline, an italic footnote, a 12px footer -- into one uniform block in
63
- * the theme's default face. This variant keeps the same structural whitelist
64
- * (plus `SPAN`/`IMG`, which carry real content in emails) and preserves a
65
- * whitelist of typographic/spacing style properties per element; everything
66
- * else (mso-*, classes, ids, event handlers) is still stripped.
67
- */
68
- const IMPORT_OK = Object.assign({ SPAN: 1, IMG: 1 }, PASTE_OK);
69
- const IMPORT_STYLES = [
70
- 'font-size', 'font-weight', 'font-style', 'font-family', 'color', 'line-height',
71
- 'letter-spacing', 'text-align', 'text-decoration', 'text-transform',
72
- 'background-color', 'border-radius', 'padding', 'margin', 'word-break',
73
- 'width', 'max-width', 'height',
74
- ];
75
-
76
- export const cleanImportHtml = (html) => {
77
- const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
78
- doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
79
- const walk = (node) => {
80
- Array.from(node.children).forEach((el) => {
81
- walk(el);
82
- if (!IMPORT_OK[el.tagName]) {
83
- const parent = el.parentNode;
84
- while (el.firstChild) parent.insertBefore(el.firstChild, el);
85
- parent.removeChild(el);
86
- return;
87
- }
88
- const kept = [];
89
- IMPORT_STYLES.forEach((p) => {
90
- const v = el.style.getPropertyValue(p);
91
- if (v) kept.push(p + ':' + v);
92
- });
93
- Array.from(el.attributes).forEach((at) => {
94
- const keep = (el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1)
95
- || (el.tagName === 'IMG' && ['src', 'alt', 'width', 'height'].indexOf(at.name) > -1);
96
- if (!keep) el.removeAttribute(at.name);
97
- });
98
- if (kept.length) el.setAttribute('style', kept.join(';'));
99
- });
100
- };
101
- walk(doc.body);
102
- return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
103
- };
104
-
105
- /**
106
- * Makes an image URL safe to drop inside `url("...")`.
107
- *
108
- * These URLs are not ours: they come from a storage provider's backend, or from
109
- * a user typing into the "Background image URL" field. They are then
110
- * interpolated into CSS -- in the library tiles, in the canvas, and (worst) into
111
- * a `style="..."` attribute in the exported email, where an unescaped quote ends
112
- * the attribute and everything after it becomes markup in someone's campaign.
113
- *
114
- * Percent-encoding rather than backslash-escaping, because the same value has to
115
- * survive two different contexts: `\"` is correct in CSS but is still a literal
116
- * quote to an HTML attribute parser. `%22` is safe in both, and resolves
117
- * identically when the browser fetches it.
118
- *
119
- * Schemes are allowlisted as well. A relative path has no scheme and is fine;
120
- * anything exotic resolves to nothing rather than being handed to the parser.
121
- */
122
- const CSS_URL_ESCAPE = { '"': '%22', "'": '%27', '(': '%28', ')': '%29', '\\': '%5C' };
123
-
124
- export const cssUrl = (u) => {
125
- const raw = String(u == null ? '' : u).trim();
126
- if (!raw) return '';
127
- const scheme = raw.match(/^([a-z][a-z0-9+.-]*):/i);
128
- if (scheme && !/^(https?|blob)$/i.test(scheme[1]) && !/^data:image\//i.test(raw)) return '';
129
- // An explicit table, not encodeURIComponent: that leaves ( ) ' untouched,
130
- // and an unescaped paren closes url(...) just as surely as a quote does.
131
- return raw.replace(/["'()\\]|\s/g, (c) => CSS_URL_ESCAPE[c] || '%20');
132
- };
133
-
134
- export const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
135
-
136
- /** Minimal HTML syntax highlighter for the code view. */
137
- export const hl = (src) => escHtml(src)
138
- .replace(/([\w-]+)=&quot;([^&]*)&quot;/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">&quot;$2&quot;</span>')
139
- .replace(/([\w-]+)="([^"]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
140
- .replace(/(&lt;\/?)([a-zA-Z][\w-]*)/g, '$1<span style="color:#5980a6;font-weight:600">$2</span>')
141
- .replace(/(\{\{[^{}]*\}\})/g, '<span style="background:rgba(89,128,166,0.18);color:#2c455d;border-radius:2px">$1</span>');
1
+ /** Scopes authored CSS to the email sheet so a raw block can never restyle the editor chrome. Ported verbatim: manual brace-matching, not the CSSOM, so arbitrary/partial CSS still scopes instead of failing to parse. */
2
+ export const scopeCss = (css, root) => {
3
+ const src = String(css || '');
4
+ let out = ''; let i = 0;
5
+ const prefix = (sel) => sel.split(',').map((s) => {
6
+ const t = s.trim();
7
+ if (!t) return '';
8
+ if (/^(from|to|\d+%)$/i.test(t)) return t;
9
+ if (t.startsWith('@')) return t;
10
+ if (/^(html|body|:root)\b/i.test(t)) return root + t.replace(/^(html|body|:root)/i, '');
11
+ return root + ' ' + t;
12
+ }).filter(Boolean).join(', ');
13
+ while (i < src.length) {
14
+ const brace = src.indexOf('{', i);
15
+ if (brace < 0) { out += src.slice(i); break; }
16
+ const head = src.slice(i, brace).trim();
17
+ let depth = 1; let j = brace + 1;
18
+ while (j < src.length && depth > 0) { if (src[j] === '{') depth++; else if (src[j] === '}') depth--; j++; }
19
+ const body = src.slice(brace + 1, j - 1);
20
+ if (head.startsWith('@')) {
21
+ out += head + '{' + (/^@(media|supports|layer|container)/i.test(head) ? scopeCss(body, root) : body) + '}';
22
+ } else {
23
+ out += prefix(head) + '{' + body + '}';
24
+ }
25
+ i = j;
26
+ }
27
+ return out;
28
+ };
29
+
30
+ export const migrateTokens = (json) => String(json).replace(/\[\[\s*([\w.]+)\s*\]\]/g, '{' + '{ $1 }' + '}');
31
+
32
+ /** Paste sanitizer -- Word/Docs/Notion drop class soup, mso- properties and nested spans into the document; keep a small tag whitelist and drop attributes (href/target/rel on links survive). */
33
+ const PASTE_OK = { A: 1, B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, STRIKE: 1, BR: 1, P: 1, UL: 1, OL: 1, LI: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, BLOCKQUOTE: 1, CODE: 1, SUP: 1, SUB: 1 };
34
+
35
+ export const cleanHtml = (html) => {
36
+ const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
37
+ doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
38
+ const walk = (node) => {
39
+ Array.from(node.children).forEach((el) => {
40
+ walk(el);
41
+ if (!PASTE_OK[el.tagName]) {
42
+ const parent = el.parentNode;
43
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
44
+ parent.removeChild(el);
45
+ return;
46
+ }
47
+ Array.from(el.attributes).forEach((at) => {
48
+ const keep = el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1;
49
+ if (!keep) el.removeAttribute(at.name);
50
+ });
51
+ });
52
+ };
53
+ walk(doc.body);
54
+ return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
55
+ };
56
+
57
+ /**
58
+ * Import sanitizer. Pasting wants `cleanHtml`'s scorched-earth policy (Word/
59
+ * Docs class soup adds nothing), but an HTML *import* is the opposite case:
60
+ * a hand-built email's inline styles ARE the design. Running imports through
61
+ * the paste path collapsed every distinctly-styled paragraph -- a 26px/800
62
+ * headline, an italic footnote, a 12px footer -- into one uniform block in
63
+ * the theme's default face. This variant keeps the same structural whitelist
64
+ * (plus `SPAN`/`IMG`, which carry real content in emails) and preserves a
65
+ * whitelist of typographic/spacing style properties per element; everything
66
+ * else (mso-*, classes, ids, event handlers) is still stripped.
67
+ */
68
+ const IMPORT_OK = Object.assign({ SPAN: 1, IMG: 1 }, PASTE_OK);
69
+ const IMPORT_STYLES = [
70
+ 'font-size', 'font-weight', 'font-style', 'font-family', 'color', 'line-height',
71
+ 'letter-spacing', 'text-align', 'text-decoration', 'text-transform',
72
+ 'background-color', 'border-radius', 'padding', 'margin', 'word-break',
73
+ 'width', 'max-width', 'height',
74
+ ];
75
+
76
+ export const cleanImportHtml = (html) => {
77
+ const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
78
+ doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
79
+ const walk = (node) => {
80
+ Array.from(node.children).forEach((el) => {
81
+ walk(el);
82
+ if (!IMPORT_OK[el.tagName]) {
83
+ const parent = el.parentNode;
84
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
85
+ parent.removeChild(el);
86
+ return;
87
+ }
88
+ const kept = [];
89
+ IMPORT_STYLES.forEach((p) => {
90
+ const v = el.style.getPropertyValue(p);
91
+ if (v) kept.push(p + ':' + v);
92
+ });
93
+ Array.from(el.attributes).forEach((at) => {
94
+ const keep = (el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1)
95
+ || (el.tagName === 'IMG' && ['src', 'alt', 'width', 'height'].indexOf(at.name) > -1);
96
+ if (!keep) el.removeAttribute(at.name);
97
+ });
98
+ if (kept.length) el.setAttribute('style', kept.join(';'));
99
+ });
100
+ };
101
+ walk(doc.body);
102
+ return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
103
+ };
104
+
105
+ /**
106
+ * Makes an image URL safe to drop inside `url("...")`.
107
+ *
108
+ * These URLs are not ours: they come from a storage provider's backend, or from
109
+ * a user typing into the "Background image URL" field. They are then
110
+ * interpolated into CSS -- in the library tiles, in the canvas, and (worst) into
111
+ * a `style="..."` attribute in the exported email, where an unescaped quote ends
112
+ * the attribute and everything after it becomes markup in someone's campaign.
113
+ *
114
+ * Percent-encoding rather than backslash-escaping, because the same value has to
115
+ * survive two different contexts: `\"` is correct in CSS but is still a literal
116
+ * quote to an HTML attribute parser. `%22` is safe in both, and resolves
117
+ * identically when the browser fetches it.
118
+ *
119
+ * Schemes are allowlisted as well. A relative path has no scheme and is fine;
120
+ * anything exotic resolves to nothing rather than being handed to the parser.
121
+ */
122
+ const CSS_URL_ESCAPE = { '"': '%22', "'": '%27', '(': '%28', ')': '%29', '\\': '%5C' };
123
+
124
+ export const cssUrl = (u) => {
125
+ const raw = String(u == null ? '' : u).trim();
126
+ if (!raw) return '';
127
+ const scheme = raw.match(/^([a-z][a-z0-9+.-]*):/i);
128
+ if (scheme && !/^(https?|blob)$/i.test(scheme[1]) && !/^data:image\//i.test(raw)) return '';
129
+ // An explicit table, not encodeURIComponent: that leaves ( ) ' untouched,
130
+ // and an unescaped paren closes url(...) just as surely as a quote does.
131
+ return raw.replace(/["'()\\]|\s/g, (c) => CSS_URL_ESCAPE[c] || '%20');
132
+ };
133
+
134
+ export const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
135
+
136
+ /** Minimal HTML syntax highlighter for the code view. */
137
+ export const hl = (src) => escHtml(src)
138
+ .replace(/([\w-]+)=&quot;([^&]*)&quot;/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">&quot;$2&quot;</span>')
139
+ .replace(/([\w-]+)="([^"]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
140
+ .replace(/(&lt;\/?)([a-zA-Z][\w-]*)/g, '$1<span style="color:#5980a6;font-weight:600">$2</span>')
141
+ .replace(/(\{\{[^{}]*\}\})/g, '<span style="background:rgba(89,128,166,0.18);color:#2c455d;border-radius:2px">$1</span>');