@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.
- package/DOCS.md +238 -10
- package/README.md +11 -3
- package/README.md.txt +134 -0
- package/dist/mailcraft-editor.bundle.js +51 -39
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/order-confirmed.html +80 -80
- package/examples/vanilla.html +234 -22
- package/package.json +7 -2
- package/src/core/assets.js +10 -15
- package/src/core/binder.js +120 -118
- package/src/core/blocks.js +14 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +1530 -1492
- package/src/core/export.js +142 -55
- package/src/core/i18n/ar.js +219 -177
- package/src/core/i18n/bg.js +196 -152
- package/src/core/i18n/bn.js +218 -176
- package/src/core/i18n/ca.js +196 -152
- package/src/core/i18n/cs.js +196 -152
- package/src/core/i18n/da.js +196 -152
- package/src/core/i18n/de-CH.js +196 -152
- package/src/core/i18n/de.js +196 -152
- package/src/core/i18n/dz.js +221 -179
- package/src/core/i18n/el.js +196 -152
- package/src/core/i18n/en.js +3 -10
- package/src/core/i18n/es.js +196 -152
- package/src/core/i18n/et.js +196 -152
- package/src/core/i18n/fi.js +196 -152
- package/src/core/i18n/fr.js +196 -152
- package/src/core/i18n/hr.js +196 -152
- package/src/core/i18n/hu.js +196 -152
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +196 -152
- package/src/core/i18n/lt.js +196 -152
- package/src/core/i18n/lv.js +196 -152
- package/src/core/i18n/nb.js +196 -152
- package/src/core/i18n/nl.js +196 -152
- package/src/core/i18n/pl.js +196 -152
- package/src/core/i18n/pt.js +196 -152
- package/src/core/i18n/ro.js +196 -152
- package/src/core/i18n/ru.js +196 -152
- package/src/core/i18n/sk.js +196 -152
- package/src/core/i18n/sl.js +196 -152
- package/src/core/i18n/sv.js +196 -152
- package/src/core/i18n/tables.js +50 -50
- package/src/core/i18n/tr.js +196 -152
- package/src/core/i18n/uk.js +196 -152
- package/src/core/icons.js +237 -235
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +1025 -959
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +141 -141
- package/src/core/storage-limits.js +184 -184
- package/src/core/storage.js +85 -85
- package/src/core/theme.js +1 -1
- package/src/core/variables.js +11 -11
- package/src/index.js +9 -9
- package/src/mailcraft-editor.js +26 -14
- package/src/render/block-body.js +49 -6
- package/src/render/canvas.js +31 -2
- package/src/render/fields.js +602 -588
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +241 -212
- package/src/render/screenshot.js +132 -132
- package/src/render/story.js +415 -415
- package/src/render/style.js +8 -0
- package/types/index.d.ts +419 -0
package/src/core/layout-style.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/placeholder.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/sanitize.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
135
|
-
|
|
136
|
-
/** Minimal HTML syntax highlighter for the code view. */
|
|
137
|
-
export const hl = (src) => escHtml(src)
|
|
138
|
-
.replace(/([\w-]+)="([^&]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
|
|
139
|
-
.replace(/([\w-]+)="([^"]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
|
|
140
|
-
.replace(/(<\/?)([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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
135
|
+
|
|
136
|
+
/** Minimal HTML syntax highlighter for the code view. */
|
|
137
|
+
export const hl = (src) => escHtml(src)
|
|
138
|
+
.replace(/([\w-]+)="([^&]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
|
|
139
|
+
.replace(/([\w-]+)="([^"]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
|
|
140
|
+
.replace(/(<\/?)([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>');
|