@seliseblocks/mailcraft 0.1.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 (89) hide show
  1. package/DOCS.md +557 -0
  2. package/LICENSE +21 -0
  3. package/README.md +126 -0
  4. package/dist/mailcraft-editor.bundle.js +519 -0
  5. package/dist/mailcraft-editor.bundle.js.map +7 -0
  6. package/examples/templates/activate-your-account.html +54 -0
  7. package/examples/templates/back-in-stock.html +63 -0
  8. package/examples/templates/cart-left-behind.html +74 -0
  9. package/examples/templates/community-giveaway.html +81 -0
  10. package/examples/templates/frontend-futures-invite.html +69 -0
  11. package/examples/templates/give-25-get-25.html +69 -0
  12. package/examples/templates/invoice-paid.html +77 -0
  13. package/examples/templates/meet-nova-launch.html +69 -0
  14. package/examples/templates/mega-weekend-sale.html +57 -0
  15. package/examples/templates/order-confirmed.html +80 -0
  16. package/examples/templates/rate-your-headphones.html +71 -0
  17. package/examples/templates/reset-your-password.html +54 -0
  18. package/examples/templates/thankyou-promo-code.html +68 -0
  19. package/examples/templates/the-sunday-brief.html +47 -0
  20. package/examples/templates/welcome-to-your-workspace.html +62 -0
  21. package/examples/templates/your-order-shipped.html +74 -0
  22. package/examples/templates/your-password-was-changed.html +58 -0
  23. package/examples/templates/your-signin-code.html +58 -0
  24. package/examples/vanilla.html +1574 -0
  25. package/package.json +66 -0
  26. package/src/core/accent.js +194 -0
  27. package/src/core/assets.js +15 -0
  28. package/src/core/binder.js +119 -0
  29. package/src/core/blocks.js +256 -0
  30. package/src/core/css-cascade.js +117 -0
  31. package/src/core/editor-core.js +1492 -0
  32. package/src/core/export.js +58 -0
  33. package/src/core/footer.js +73 -0
  34. package/src/core/i18n/ar.js +177 -0
  35. package/src/core/i18n/bg.js +152 -0
  36. package/src/core/i18n/bn.js +176 -0
  37. package/src/core/i18n/ca.js +152 -0
  38. package/src/core/i18n/cs.js +152 -0
  39. package/src/core/i18n/da.js +152 -0
  40. package/src/core/i18n/de-CH.js +152 -0
  41. package/src/core/i18n/de.js +152 -0
  42. package/src/core/i18n/dz.js +179 -0
  43. package/src/core/i18n/el.js +152 -0
  44. package/src/core/i18n/en.js +269 -0
  45. package/src/core/i18n/es.js +152 -0
  46. package/src/core/i18n/et.js +152 -0
  47. package/src/core/i18n/fi.js +152 -0
  48. package/src/core/i18n/fr.js +152 -0
  49. package/src/core/i18n/hr.js +152 -0
  50. package/src/core/i18n/hu.js +152 -0
  51. package/src/core/i18n/index.js +83 -0
  52. package/src/core/i18n/it.js +152 -0
  53. package/src/core/i18n/lt.js +152 -0
  54. package/src/core/i18n/lv.js +152 -0
  55. package/src/core/i18n/nb.js +152 -0
  56. package/src/core/i18n/nl.js +152 -0
  57. package/src/core/i18n/pl.js +152 -0
  58. package/src/core/i18n/pt.js +152 -0
  59. package/src/core/i18n/ro.js +152 -0
  60. package/src/core/i18n/ru.js +152 -0
  61. package/src/core/i18n/sk.js +152 -0
  62. package/src/core/i18n/sl.js +152 -0
  63. package/src/core/i18n/sv.js +152 -0
  64. package/src/core/i18n/tables.js +50 -0
  65. package/src/core/i18n/tr.js +152 -0
  66. package/src/core/i18n/uk.js +152 -0
  67. package/src/core/icons.js +235 -0
  68. package/src/core/ids.js +1 -0
  69. package/src/core/import-html.js +959 -0
  70. package/src/core/layout-style.js +115 -0
  71. package/src/core/parse.js +10 -0
  72. package/src/core/placeholder.js +15 -0
  73. package/src/core/sanitize.js +141 -0
  74. package/src/core/storage-limits.js +184 -0
  75. package/src/core/storage.js +85 -0
  76. package/src/core/theme.js +1 -0
  77. package/src/core/toolbar.js +67 -0
  78. package/src/core/variables.js +11 -0
  79. package/src/create-editor.js +109 -0
  80. package/src/index.js +9 -0
  81. package/src/mailcraft-editor.js +1862 -0
  82. package/src/render/block-body.js +295 -0
  83. package/src/render/canvas.js +284 -0
  84. package/src/render/fields.js +609 -0
  85. package/src/render/focus-preserve.js +158 -0
  86. package/src/render/rte.js +212 -0
  87. package/src/render/screenshot.js +132 -0
  88. package/src/render/story.js +415 -0
  89. package/src/render/style.js +452 -0
@@ -0,0 +1,115 @@
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
+ 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';
44
+ }
45
+
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. */
47
+ export function rowMargin(p, centerEmpty) {
48
+ const t = p.mt ?? p.my ?? 0;
49
+ const r = p.mr ?? 0;
50
+ const b = p.mb ?? p.my ?? 0;
51
+ const l = p.ml ?? 0;
52
+ const emptyHorizontal = centerEmpty && !r && !l;
53
+ return t + 'px ' + (emptyHorizontal ? 'auto' : r + 'px') + ' ' + b + 'px ' + (emptyHorizontal ? 'auto' : l + 'px');
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) + '%',
92
+ margin: rowMargin(p, true),
93
+ };
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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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>');
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Upload validation -- mechanism only, no policy.
3
+ *
4
+ * Deliberately ships no default ceilings. What an email template may carry
5
+ * depends on the sending platform (ESP attachment caps), the audience's
6
+ * clients (Outlook's Word engine won't render WebP; nothing renders AVIF) and
7
+ * the host's own product rules, so the numbers are the host's to set:
8
+ * `editor.storageLimits`, or `limits` on the provider. With a provider wired
9
+ * and no limits declared, uploads are refused rather than waved through.
10
+ *
11
+ * Checks run *before* the provider is called, so a rejected file never reaches
12
+ * the backend. That matters for any store where minting an upload URL also
13
+ * creates the file record: validating afterwards would leave an orphan behind
14
+ * for every rejection.
15
+ *
16
+ * Types are decided by sniffing the leading bytes, not by trusting `file.type`:
17
+ * the browser fills that in from the file extension, so renaming `payload.svg`
18
+ * to `photo.png` is enough to walk a script-bearing document past a MIME check
19
+ * and into the editor's own DOM, where the library preview renders it.
20
+ */
21
+
22
+ import { KB } from './assets.js';
23
+
24
+ const ascii = (b, at, s) => s.split('').every((c, i) => b[at + i] === c.charCodeAt(0));
25
+
26
+ /**
27
+ * The leading bytes of every raster format a mail client might plausibly be
28
+ * asked to show, plus the ones it can't -- knowing a file is AVIF is what lets
29
+ * the rejection say "AVIF" instead of "unsupported".
30
+ */
31
+ function sniff(b) {
32
+ if (b.length < 12) return null;
33
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
34
+ if (b[0] === 0x89 && ascii(b, 1, 'PNG')) return 'image/png';
35
+ if (ascii(b, 0, 'GIF8')) return 'image/gif';
36
+ if (ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP')) return 'image/webp';
37
+ if (ascii(b, 0, 'BM')) return 'image/bmp';
38
+ if ((ascii(b, 0, 'II') && b[2] === 0x2a && b[3] === 0) || (ascii(b, 0, 'MM') && b[2] === 0 && b[3] === 0x2a)) return 'image/tiff';
39
+ if (b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0) return 'image/x-icon';
40
+ if (ascii(b, 4, 'ftyp')) {
41
+ const brand = String.fromCharCode(b[8], b[9], b[10], b[11]);
42
+ if (brand === 'avif' || brand === 'avis') return 'image/avif';
43
+ return 'image/heic';
44
+ }
45
+ // Markup: an SVG may open with a BOM, an XML declaration, a doctype or a
46
+ // comment before the root element, so the reliable tell is "text that starts
47
+ // with '<'", not a literal "<svg" at offset zero.
48
+ let i = 0;
49
+ if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) i = 3;
50
+ while (i < b.length && (b[i] === 0x20 || b[i] === 0x09 || b[i] === 0x0a || b[i] === 0x0d)) i++;
51
+ if (b[i] === 0x3c) return 'image/svg+xml';
52
+ return null;
53
+ }
54
+
55
+ /** First bytes of a file. `Blob.arrayBuffer` where it exists, FileReader where it doesn't. */
56
+ function head(file) {
57
+ const slice = file.slice(0, 32);
58
+ if (typeof slice.arrayBuffer === 'function') {
59
+ return slice.arrayBuffer().then((buf) => new Uint8Array(buf)).catch(() => null);
60
+ }
61
+ return new Promise((resolve) => {
62
+ const fr = new FileReader();
63
+ fr.onload = () => resolve(new Uint8Array(fr.result));
64
+ fr.onerror = () => resolve(null);
65
+ fr.readAsArrayBuffer(slice);
66
+ });
67
+ }
68
+
69
+ /** Decoded pixel dimensions, or `null` when the browser can't decode it -- which is itself a reason to refuse the file. */
70
+ function probe(file) {
71
+ return new Promise((resolve) => {
72
+ const url = URL.createObjectURL(file);
73
+ const img = new Image();
74
+ const done = (v) => { URL.revokeObjectURL(url); resolve(v); };
75
+ img.onload = () => done({ w: img.naturalWidth || img.width, ht: img.naturalHeight || img.height });
76
+ img.onerror = () => done(null);
77
+ img.src = url;
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Strips directory separators, control characters and anything a storage
83
+ * backend is likely to treat as path syntax, and caps the length. A name
84
+ * arrives from the user's disk and ends up in a URL.
85
+ */
86
+ export function sanitizeName(name) {
87
+ const clean = String(name || 'file')
88
+ .replace(/[\\/]/g, '-')
89
+ .replace(/[<>:"|?*]/g, '')
90
+ .split('').filter((c) => c.charCodeAt(0) > 31).join('')
91
+ .trim()
92
+ .replace(/^\.+/, '') || 'file';
93
+ if (clean.length <= 120) return clean;
94
+ const dot = clean.lastIndexOf('.');
95
+ const ext = dot > 0 && clean.length - dot <= 8 ? clean.slice(dot) : '';
96
+ return clean.slice(0, 120 - ext.length) + ext;
97
+ }
98
+
99
+ /** The host has to have said what's allowed. Format and size are the two that matter; the rest are optional tightenings. */
100
+ export function limitsProblem(limits) {
101
+ if (!limits) return 'storage.errNoLimits';
102
+ if (!Array.isArray(limits.accept) || !limits.accept.length) return 'storage.errNoAccept';
103
+ if (!(Number(limits.maxBytes) > 0)) return 'storage.errNoMaxBytes';
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Splits a FileList into what may be uploaded and what may not.
109
+ *
110
+ * Rejections carry an i18n key and its params rather than a sentence, so the
111
+ * reason is translated by the same table as the rest of the chrome.
112
+ *
113
+ * @returns {Promise<{accepted: Array<{file: File, name: string, w: number, ht: number, size: number, type: string}>, rejected: Array<{name: string, key: string, params: Object}>}>}
114
+ */
115
+ export async function validateFiles(list, limits) {
116
+ const files = Array.from(list || []);
117
+ const accepted = [];
118
+ const rejected = [];
119
+ if (!files.length) return { accepted, rejected };
120
+
121
+ const problem = limitsProblem(limits);
122
+ if (problem) return { accepted, rejected: files.map((f) => ({ name: f.name, key: problem, params: {} })) };
123
+
124
+ const accept = limits.accept.map((m) => String(m).toLowerCase());
125
+ const max = Number(limits.maxBytes);
126
+ const maxW = Number(limits.maxWidth) || 0;
127
+ const maxH = Number(limits.maxHeight) || 0;
128
+ const perDrop = Number(limits.maxFilesPerDrop) || 0;
129
+
130
+ const queue = perDrop && files.length > perDrop ? files.slice(0, perDrop) : files;
131
+ if (queue.length < files.length) {
132
+ files.slice(queue.length).forEach((f) => rejected.push({ name: f.name, key: 'storage.errTooMany', params: { max: perDrop } }));
133
+ }
134
+
135
+ for (const file of queue) {
136
+ const name = sanitizeName(file.name);
137
+
138
+ if (file.size > max) {
139
+ rejected.push({ name, key: 'storage.errTooLarge', params: { name, size: KB(file.size), max: KB(max) } });
140
+ continue;
141
+ }
142
+
143
+ const bytes = await head(file);
144
+ const type = bytes && sniff(bytes);
145
+ if (!type) {
146
+ rejected.push({ name, key: 'storage.errUnreadable', params: { name } });
147
+ continue;
148
+ }
149
+ if (!accept.includes(type)) {
150
+ rejected.push({ name, key: 'storage.errFormat', params: { name, type: type.replace(/^image\//, '').toUpperCase() } });
151
+ continue;
152
+ }
153
+ // Listing SVG in `accept` is not on its own enough. It is the one image
154
+ // type that is also a script host, and it renders inside the editor's own
155
+ // shadow root the moment it appears as a library tile -- so a host that
156
+ // genuinely wants it has to say so twice, and can never enable it by
157
+ // pasting a permissive MIME list.
158
+ if (type === 'image/svg+xml' && !limits.allowSvg) {
159
+ rejected.push({ name, key: 'storage.errSvg', params: { name } });
160
+ continue;
161
+ }
162
+
163
+ let dims = { w: 0, ht: 0 };
164
+ if (type !== 'image/svg+xml') {
165
+ const measured = await probe(file);
166
+ if (!measured) { rejected.push({ name, key: 'storage.errUnreadable', params: { name } }); continue; }
167
+ dims = measured;
168
+ if ((maxW && dims.w > maxW) || (maxH && dims.ht > maxH)) {
169
+ rejected.push({ name, key: 'storage.errDimensions', params: { name, w: dims.w, ht: dims.ht, maxW: maxW || dims.w, maxH: maxH || dims.ht } });
170
+ continue;
171
+ }
172
+ }
173
+
174
+ accepted.push({ file, name, type, size: file.size, w: dims.w, ht: dims.ht });
175
+ }
176
+
177
+ return { accepted, rejected };
178
+ }
179
+
180
+ /** An `accept` attribute for the file picker, so the OS dialog greys out what validation would refuse anyway. */
181
+ export function acceptAttribute(limits) {
182
+ if (!limits || !Array.isArray(limits.accept) || !limits.accept.length) return 'image/*';
183
+ return limits.accept.join(',');
184
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The storage contract.
3
+ *
4
+ * This file is deliberately transport-free: no `fetch`, no auth, no origin, no
5
+ * backend of any kind. The editor talks to a plain object supplied by the host
6
+ * (`editor.storageProvider = …`) in exactly the way it talks to `aiProvider` --
7
+ * so a host can back the file library with S3, a DMS, a CDN or its own proxy
8
+ * without this package knowing which.
9
+ *
10
+ * No adapter ships here, deliberately. An adapter is a mapping onto somebody
11
+ * else's API surface, so vendoring one would mean republishing this package
12
+ * every time that surface moves. The host writes its own, next to the auth and
13
+ * base URL it already owns.
14
+ *
15
+ * @typedef {Object} Asset
16
+ * @property {string} id Stable id. With a provider this is the backend's file id -- `remove()` gets it back verbatim.
17
+ * @property {string} name Display/file name.
18
+ * @property {string} url Resolvable image URL. Must outlive the send: an email renders it long after the editor closed.
19
+ * @property {string} folder Folder display name.
20
+ * @property {string} [folderId] Backend folder id, when the provider has one.
21
+ * @property {number} w Pixel width (0 when unknown).
22
+ * @property {number} ht Pixel height (0 when unknown).
23
+ * @property {number} size Bytes.
24
+ *
25
+ * @typedef {Object} StorageProvider
26
+ * @property {() => Promise<Array<{id: string, name: string}>>} [folders]
27
+ * Selectable folders. Omit for a flat library.
28
+ * @property {(q: {folderId: string, cursor: ?string, query: string}) => Promise<{items: Asset[], cursor: ?string}>} list
29
+ * One page of assets. `cursor` is opaque -- whatever the provider returned last, handed back to fetch the next page.
30
+ * @property {(file: File, o: {folderId: string, width: number, height: number, signal: ?AbortSignal}) => Promise<Asset>} upload
31
+ * Stores one already-validated file and resolves to the asset that represents it.
32
+ * @property {(asset: Asset) => Promise<void>} [remove]
33
+ * Deletes. Without it the library's DEL only drops the tile from view.
34
+ * @property {StorageLimits} [limits]
35
+ * Provider-declared ceilings. `editor.storageLimits` wins over these.
36
+ *
37
+ * @typedef {Object} StorageLimits
38
+ * @property {string[]} accept Allowed MIME types, e.g. `['image/jpeg','image/png','image/gif']`. Required.
39
+ * @property {number} maxBytes Per-file byte ceiling. Required.
40
+ * @property {number} [maxWidth]
41
+ * @property {number} [maxHeight]
42
+ * @property {number} [maxFilesPerDrop]
43
+ * @property {boolean} [allowSvg] SVG is refused even when listed in `accept` unless this is also true -- see `storage-limits.js`.
44
+ */
45
+
46
+ /** The synthetic "everything" folder. Its id is empty so a provider reading `folderId` sees "no folder filter", not a magic name. */
47
+ export const ALL_FOLDER_ID = '';
48
+
49
+ /**
50
+ * Coerces whatever a provider returned into the shape the renderer indexes
51
+ * into. A provider that forgets `w`/`ht` should degrade to a tile without
52
+ * dimensions, not to `undefined×undefined` printed in the UI -- and `probe`
53
+ * (what the limits check already measured client-side) fills those in for
54
+ * backends that don't store image dimensions at all.
55
+ */
56
+ export function normalizeAsset(raw, probe) {
57
+ const a = raw || {};
58
+ return {
59
+ id: String(a.id ?? a.itemId ?? ''),
60
+ name: String(a.name ?? (probe && probe.name) ?? 'file'),
61
+ url: String(a.url ?? ''),
62
+ folder: String(a.folder ?? ''),
63
+ folderId: a.folderId != null ? String(a.folderId) : undefined,
64
+ w: Number(a.w ?? (probe && probe.w) ?? 0) || 0,
65
+ ht: Number(a.ht ?? (probe && probe.ht) ?? 0) || 0,
66
+ size: Number(a.size ?? (probe && probe.size) ?? 0) || 0,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * `editor.storageLimits` over `provider.limits`, per key rather than
72
+ * wholesale: a host that only wants to tighten `maxBytes` shouldn't have to
73
+ * restate the provider's `accept` list to do it.
74
+ */
75
+ export function resolveLimits(hostLimits, providerLimits) {
76
+ if (!hostLimits && !providerLimits) return null;
77
+ return Object.assign({}, providerLimits || {}, hostLimits || {});
78
+ }
79
+
80
+ /** Names the methods a provider is missing, for a loud failure at assignment rather than a quiet one on first upload. */
81
+ export function providerProblems(p) {
82
+ if (!p || typeof p !== 'object') return ['storageProvider must be an object'];
83
+ const missing = ['list', 'upload'].filter((k) => typeof p[k] !== 'function');
84
+ return missing.length ? [`storageProvider is missing ${missing.join(' and ')}`] : [];
85
+ }
@@ -0,0 +1 @@
1
+ export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Which parts of the editor's own top bar are shown.
3
+ *
4
+ * Hosts embed the editor inside chrome they already own -- a page header, a
5
+ * breadcrumb, their own Save/Send buttons -- where a second bar with a second
6
+ * logo reads as two applications stacked on top of each other. The bar is
7
+ * therefore configurable down to the individual control, and can be removed
8
+ * entirely.
9
+ *
10
+ * What that costs: `undo`, `redo` and `export` have element-level equivalents
11
+ * (`undo()`, `redo()`, `exportHtml()`), so hiding those is only declining to
12
+ * render the button. `preview`, `code` and `ai` open panels that nothing in
13
+ * the public API opens -- a host that hides them is giving the panel up, not
14
+ * just the button. DOCS.md carries the same table for integrators.
15
+ *
16
+ * Keyboard shortcuts are unaffected by any of this -- they are bound on the
17
+ * document, not on the bar -- so Ctrl/Cmd+Z, Shift+Ctrl/Cmd+Z and Ctrl/Cmd+E
18
+ * (the export dialog, and the Screenshot button inside it) work with no bar
19
+ * at all.
20
+ */
21
+
22
+ /** Every switchable part, in the order it appears in the bar. */
23
+ export const TOOLBAR_ITEMS = ['logo', 'status', 'device', 'undo', 'redo', 'theme', 'ai', 'code', 'preview', 'export'];
24
+
25
+ const HIDDEN = ['none', 'hidden', 'off', 'false'];
26
+
27
+ /**
28
+ * Normalizes whatever the host set into `{ item: boolean }`, or `null` for
29
+ * "no bar at all".
30
+ *
31
+ * Accepts, in the two shapes a Web Component gets configured through:
32
+ *
33
+ * - property: `false` (no bar), or an object of overrides where only the
34
+ * keys set to `false` are turned off -- so `{ logo: false }` keeps every
35
+ * control and drops just the brand, without restating the other nine.
36
+ * - attribute: `"none"` (no bar), or a comma list naming the items to keep
37
+ * (`toolbar="undo,redo,export"`). Markup has only strings to work with,
38
+ * and an allow-list is the readable half of the two: the alternative is
39
+ * spelling out the seven things you did not want.
40
+ *
41
+ * Unset means everything is shown -- the editor is fully usable standalone,
42
+ * and configuring the bar is opt-in.
43
+ *
44
+ * An empty result (every item switched off) collapses to `null`: an empty
45
+ * 54px bar is not what anyone meant by turning off the last control.
46
+ */
47
+ export function resolveToolbar(value) {
48
+ if (value === false) return null;
49
+ if (typeof value === 'string' && HIDDEN.indexOf(value.trim().toLowerCase()) >= 0) return null;
50
+
51
+ const on = {};
52
+ TOOLBAR_ITEMS.forEach((k) => { on[k] = true; });
53
+
54
+ if (value && typeof value === 'object') {
55
+ TOOLBAR_ITEMS.forEach((k) => { if (value[k] === false) on[k] = false; });
56
+ } else if (typeof value === 'string' && value.trim() && value.trim().toLowerCase() !== 'all') {
57
+ const keep = value.split(',').map((s) => s.trim()).filter(Boolean);
58
+ TOOLBAR_ITEMS.forEach((k) => { on[k] = keep.indexOf(k) >= 0; });
59
+ }
60
+
61
+ return TOOLBAR_ITEMS.some((k) => on[k]) ? on : null;
62
+ }
63
+
64
+ /** Stable string for a resolved config, so a re-set that changes nothing does not cost a shell rebuild. */
65
+ export function toolbarKey(on) {
66
+ return on ? TOOLBAR_ITEMS.map((k) => (on[k] ? '1' : '0')).join('') : 'none';
67
+ }
@@ -0,0 +1,11 @@
1
+ export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
2
+ export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
3
+
4
+ /** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
5
+ export function vars(raw) {
6
+ const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
7
+ return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
8
+ }
9
+
10
+ /** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
11
+ export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };