@seliseblocks/mailcraft 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/DOCS.md +243 -16
  2. package/README.md +15 -6
  3. package/README.md.txt +135 -0
  4. package/dist/mailcraft-editor.bundle.js +87 -47
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/activate-your-account.html +1 -1
  7. package/examples/templates/back-in-stock.html +1 -1
  8. package/examples/templates/cart-left-behind.html +1 -1
  9. package/examples/templates/community-giveaway.html +1 -1
  10. package/examples/templates/frontend-futures-invite.html +1 -1
  11. package/examples/templates/give-25-get-25.html +1 -1
  12. package/examples/templates/invoice-paid.html +1 -1
  13. package/examples/templates/meet-nova-launch.html +1 -1
  14. package/examples/templates/mega-weekend-sale.html +1 -1
  15. package/examples/templates/order-confirmed.html +80 -80
  16. package/examples/templates/rate-your-headphones.html +1 -1
  17. package/examples/templates/reset-your-password.html +1 -1
  18. package/examples/templates/thankyou-promo-code.html +1 -1
  19. package/examples/templates/the-sunday-brief.html +1 -1
  20. package/examples/templates/welcome-to-your-workspace.html +1 -1
  21. package/examples/templates/your-order-shipped.html +1 -1
  22. package/examples/templates/your-password-was-changed.html +1 -1
  23. package/examples/templates/your-signin-code.html +1 -1
  24. package/examples/vanilla.html +261 -47
  25. package/package.json +7 -2
  26. package/src/core/assets.js +10 -15
  27. package/src/core/binder.js +140 -118
  28. package/src/core/blocks.js +14 -1
  29. package/src/core/css-cascade.js +117 -117
  30. package/src/core/editor-core.js +1540 -1492
  31. package/src/core/export.js +158 -55
  32. package/src/core/i18n/ar.js +218 -177
  33. package/src/core/i18n/bg.js +195 -152
  34. package/src/core/i18n/bn.js +217 -176
  35. package/src/core/i18n/ca.js +195 -152
  36. package/src/core/i18n/cs.js +195 -152
  37. package/src/core/i18n/da.js +195 -152
  38. package/src/core/i18n/de-CH.js +195 -152
  39. package/src/core/i18n/de.js +195 -152
  40. package/src/core/i18n/dz.js +220 -179
  41. package/src/core/i18n/el.js +195 -152
  42. package/src/core/i18n/en.js +3 -11
  43. package/src/core/i18n/es.js +195 -152
  44. package/src/core/i18n/et.js +195 -152
  45. package/src/core/i18n/fi.js +195 -152
  46. package/src/core/i18n/fr.js +195 -152
  47. package/src/core/i18n/hr.js +195 -152
  48. package/src/core/i18n/hu.js +195 -152
  49. package/src/core/i18n/index.js +83 -83
  50. package/src/core/i18n/it.js +195 -152
  51. package/src/core/i18n/lt.js +195 -152
  52. package/src/core/i18n/lv.js +195 -152
  53. package/src/core/i18n/nb.js +195 -152
  54. package/src/core/i18n/nl.js +195 -152
  55. package/src/core/i18n/pl.js +195 -152
  56. package/src/core/i18n/pt.js +195 -152
  57. package/src/core/i18n/ro.js +195 -152
  58. package/src/core/i18n/ru.js +195 -152
  59. package/src/core/i18n/sk.js +195 -152
  60. package/src/core/i18n/sl.js +195 -152
  61. package/src/core/i18n/sv.js +195 -152
  62. package/src/core/i18n/tables.js +50 -50
  63. package/src/core/i18n/tr.js +195 -152
  64. package/src/core/i18n/uk.js +195 -152
  65. package/src/core/icons.js +237 -235
  66. package/src/core/ids.js +1 -1
  67. package/src/core/import-html.js +1047 -959
  68. package/src/core/layout-style.js +100 -100
  69. package/src/core/parse.js +10 -10
  70. package/src/core/placeholder.js +15 -15
  71. package/src/core/sanitize.js +141 -141
  72. package/src/core/storage-limits.js +189 -184
  73. package/src/core/storage.js +85 -85
  74. package/src/core/theme.js +15 -1
  75. package/src/core/variables.js +11 -11
  76. package/src/index.js +9 -9
  77. package/src/mailcraft-editor.js +64 -25
  78. package/src/render/block-body.js +49 -6
  79. package/src/render/canvas.js +74 -8
  80. package/src/render/fields.js +619 -588
  81. package/src/render/focus-preserve.js +158 -158
  82. package/src/render/rte.js +241 -212
  83. package/src/render/screenshot.js +138 -132
  84. package/src/render/story.js +415 -415
  85. package/src/render/style.js +44 -8
  86. package/types/index.d.ts +422 -0
@@ -1,58 +1,161 @@
1
- /**
2
- * Ported verbatim from the original `buildHtml()`, including its defining
3
- * trait: it does not re-template each block from `props` a second time, it
4
- * reads back the already-rendered live DOM node for each block (`grab`) and
5
- * strips editor-only attributes. The one adaptation: the original queried
6
- * plain `document` (the whole prototype ran outside any shadow tree); this
7
- * package renders inside a Shadow DOM, so `grab` is parameterized on the root
8
- * to search (the element's `shadowRoot`) instead of hardcoding `document`.
9
- */
10
-
11
- import { cssUrl } from './sanitize.js';
1
+ /**
2
+ * Ported verbatim from the original `buildHtml()`, including its defining
3
+ * trait: it does not re-template each block from `props` a second time, it
4
+ * reads back the already-rendered live DOM node for each block (`grab`) and
5
+ * strips editor-only attributes. The one adaptation: the original queried
6
+ * plain `document` (the whole prototype ran outside any shadow tree); this
7
+ * package renders inside a Shadow DOM, so `grab` is parameterized on the root
8
+ * to search (the element's `shadowRoot`) instead of hardcoding `document`.
9
+ */
10
+
11
+ import { cssUrl } from './sanitize.js';
12
12
  import { rowBorderCss, rowMargin, rowPad } from './layout-style.js';
13
-
14
- export function buildHtml(state, root, boxCss) {
15
- const d = state.doc; const t = d.theme;
16
- const grab = (id) => {
17
- const el = root.querySelector('[data-mc-content="' + id + '"]');
18
- if (!el) return '';
19
- return el.outerHTML
20
- .replace(/\scontenteditable="[^"]*"/g, '')
21
- .replace(/\sdata-mc-[a-z-]+="[^"]*"/g, '')
22
- .replace(/\sspellcheck="[^"]*"/g, '')
23
- .replace(/\sdraggable="[^"]*"/g, '');
24
- };
25
- const rows = d.rows.map((r) => {
26
- const rp = r.props;
27
- const colInner = (c) => c.blocks.map((b) => {
28
- if (b.type === 'css') return '<style>' + (b.props.code || '') + '</style>';
29
- if (b.type === 'html') return b.props.code || '';
30
- if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0">' + (b.props.code || '') + '</div>';
31
- return '<div style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
32
- }).join('\n ') || '&nbsp;';
33
- const cells = r.cols.map((c) => {
34
- // Column-level styling (bg/radius/inner padding) renders as a wrapper
35
- // <div> inside the cell so the gutter padding stays unpainted --
36
- // mirrors render/canvas.js's `host` wrapper.
37
- const inner = (c.bg || c.border || c.radius || c.padY || c.padX)
38
- ? '<div style="background:' + (c.bg || 'transparent') + ';' + (c.border ? 'border:' + c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') + ';' : '') + 'border-radius:' + (c.radius || 0) + 'px;padding:' + (c.padY || 0) + 'px ' + (c.padX || 0) + 'px">\n ' + colInner(c) + '\n </div>'
39
- : colInner(c);
40
- return '<td width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + inner + '\n </td>';
41
- }).join('\n ');
42
- const cssBody = rp.layout === 'grid'
43
- ? '<div style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
44
- : '<div style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
45
- const body = rp.layout && rp.layout !== 'columns'
46
- ? cssBody
47
- : '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>\n ' + cells + '\n </tr></table>';
48
- const tdBg = rp.bgImage
49
- ? 'background-color:' + (rp.bg || t.contentBg) + ';background-image:' + (rp.overlay ? 'linear-gradient(rgba(20,22,24,' + (rp.overlay / 100) + '),rgba(20,22,24,' + (rp.overlay / 100) + ')),' : '') + 'url(&quot;' + cssUrl(rp.bgImage) + '&quot;);background-size:' + (rp.bgSize || 'cover') + ';background-position:' + (rp.bgPos || 'center') + ';background-repeat:' + (rp.bgRepeat || 'no-repeat') + ';'
50
- : 'background:' + (rp.bg || t.contentBg) + ';';
51
- const tdBorder = rowBorderCss(rp)
52
- + (rp.radius ? 'border-radius:' + rp.radius + 'px;' : '')
13
+
14
+ const LOGIC_OPEN = { condition: '{{#if ', loop: '{{#each ' };
15
+ const LOGIC_CLOSE = { condition: '{{/if}}', loop: '{{/each}}' };
16
+
17
+ /**
18
+ * What each dynamic-content marker block actually emits, decided over the
19
+ * whole document in reading order so the output template is always balanced
20
+ * no matter how the markers were arranged or mangled: an end with no open of
21
+ * its kind on top of the stack emits nothing, and opens still unclosed after
22
+ * the last row are closed in `tail`, appended after the final row. Markers
23
+ * emit literal template tags -- like merge tags, the editor never evaluates
24
+ * them; the host's engine runs before any mail client parses the HTML, so raw
25
+ * {{#each}} text in the markup is the standard pattern, not invalid HTML.
26
+ * Angle brackets are stripped from expressions: no Handlebars-style
27
+ * expression contains them, and it keeps a typed '<' from opening a tag in
28
+ * the sent document.
29
+ */
30
+ function logicPlan(doc) {
31
+ const expr = (v) => String(v || '').replace(/[<>]/g, '').trim();
32
+ const emit = new Map();
33
+ const stack = [];
34
+ doc.rows.forEach((r) => r.cols.forEach((c) => c.blocks.forEach((b) => {
35
+ if (b.type !== 'condition' && b.type !== 'loop') return;
36
+ if (!b.props.end) {
37
+ const e = expr(b.props.expr);
38
+ emit.set(b.id, e ? LOGIC_OPEN[b.type] + e + '}}' : '');
39
+ if (e) stack.push(b.type);
40
+ } else if (stack[stack.length - 1] === b.type) {
41
+ stack.pop();
42
+ emit.set(b.id, LOGIC_CLOSE[b.type]);
43
+ } else {
44
+ emit.set(b.id, '');
45
+ }
46
+ })));
47
+ const tail = stack.reverse().map((k) => LOGIC_CLOSE[k]).join('\n ');
48
+ return { emit, tail };
49
+ }
50
+
51
+ /**
52
+ * Display-only dressing for the Code modal's live-preview iframe: the
53
+ * exported template carries literal {{#if}}/{{#each}} tags, which an iframe
54
+ * renders as bare text at odd positions (a browser even foster-parents the
55
+ * between-row ones out of the table entirely). For *viewing*, each tag
56
+ * becomes the same dashed band the canvas draws -- tags between <tr>s become
57
+ * a slim full-width row so they hold their place in the table, tags inside a
58
+ * cell become an inline chip. Never applied to the HTML the host receives:
59
+ * exportHtml/copy/download all carry the real tags.
60
+ */
61
+ export function decorateLogicTags(html) {
62
+ const esc = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
63
+ const chip = (kind, expr, end) => {
64
+ const color = kind === 'each' ? '#7c3aed' : '#0e7490';
65
+ const word = end ? (kind === 'each' ? 'END LOOP' : 'END IF') : (kind === 'each' ? 'REPEAT EACH' : 'SHOW IF');
66
+ return '<span style="display:inline-flex;align-items:center;gap:7px;box-sizing:border-box;border:1.5px dashed ' + color + ';border-radius:7px;background:' + color + '14;padding:4px 10px;margin:2px 0;color:' + color + ';font-family:ui-monospace,monospace;font-size:9.5px;font-weight:700;letter-spacing:0.12em;">'
67
+ + (end ? '⏶ ' : '⏷ ') + word
68
+ + (expr ? ' <span style="font-weight:400;font-size:11px;letter-spacing:0;">{{ ' + esc(expr) + ' }}</span>' : '')
69
+ + '</span>';
70
+ };
71
+ const bandRow = (kind, expr, end) => '<tr><td colspan="99" style="padding:2px 8px;">' + chip(kind, expr, end) + '</td></tr>';
72
+ let s = String(html || '');
73
+ // Between-row tags first (adjacent to a <tr> or after a </tr>), looped for
74
+ // the same adjacency reason as import-html's foldLogicWrappers.
75
+ const parse = (tag) => { const m = tag.match(/^#(if|each)\s+(.+)$/); return m ? [m[1], m[2].trim(), false] : [tag.slice(1), '', true]; };
76
+ for (let i = 0; i < 6; i++) {
77
+ const before = s;
78
+ s = s.replace(/\{\{(#(?:if|each)\s+[^{}]+?|\/(?:if|each))\s*\}\}(?=\s*(?:<tr[\s>]|<\/table))/gi, (m, tag) => bandRow(...parse(tag)));
79
+ s = s.replace(/(<\/tr>\s*)\{\{(#(?:if|each)\s+[^{}]+?|\/(?:if|each))\s*\}\}/gi, (m, pre, tag) => pre + bandRow(...parse(tag)));
80
+ if (s === before) break;
81
+ }
82
+ // Whatever remains sits inside a cell (or prose): inline chips in place.
83
+ s = s.replace(/\{\{#(if|each)\s+([^{}]+?)\s*\}\}/gi, (m, kind, expr) => chip(kind.toLowerCase(), expr, false));
84
+ s = s.replace(/\{\{\/(if|each)\s*\}\}/gi, (m, kind) => chip(kind.toLowerCase(), '', true));
85
+ return s;
86
+ }
87
+
88
+ export function buildHtml(state, root, boxCss) {
89
+ const d = state.doc; const t = d.theme;
90
+ const logic = logicPlan(d);
91
+ const grab = (id) => {
92
+ const el = root.querySelector('[data-mc-content="' + id + '"]');
93
+ if (!el) return '';
94
+ return el.outerHTML
95
+ .replace(/\scontenteditable="[^"]*"/g, '')
96
+ .replace(/\sdata-mc-[a-z-]+="[^"]*"/g, '')
97
+ .replace(/\sspellcheck="[^"]*"/g, '')
98
+ .replace(/\sdata-(?:gramm|gramm_editor|enable-grammarly|lt-active)="[^"]*"/g, '')
99
+ .replace(/\sdraggable="[^"]*"/g, '');
100
+ };
101
+ const rows = d.rows.map((r) => {
102
+ const rp = r.props;
103
+ // A row holding nothing but logic markers exists to wrap the *sections*
104
+ // around it (drop a Condition onto the canvas above and below a group of
105
+ // rows). Emitting its <tr> scaffolding would leave an empty padded band
106
+ // in the sent email, so only the tags themselves are emitted -- they sit
107
+ // between <tr>s as plain text, which the host's engine consumes before
108
+ // any mail client parses the markup.
109
+ const blocksOf = r.cols.reduce((a, c) => a.concat(c.blocks), []);
110
+ if (blocksOf.length && blocksOf.every((b) => b.type === 'condition' || b.type === 'loop')) {
111
+ return blocksOf.map((b) => logic.emit.get(b.id) || '').filter(Boolean).map((s) => ' ' + s).join('\n');
112
+ }
113
+ const colInner = (c) => c.blocks.map((b) => {
114
+ if (b.type === 'css') return '<style>' + (b.props.code || '') + '</style>';
115
+ if (b.type === 'html') return b.props.code || '';
116
+ if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0">' + (b.props.code || '') + '</div>';
117
+ if (b.type === 'condition' || b.type === 'loop') return logic.emit.get(b.id) || '';
118
+ return '<div style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
119
+ }).filter(Boolean).join('\n ') || '&nbsp;';
120
+ const cells = r.cols.map((c) => {
121
+ // Column-level styling (bg/radius/inner padding) renders as a wrapper
122
+ // <div> inside the cell so the gutter padding stays unpainted --
123
+ // mirrors render/canvas.js's `host` wrapper.
124
+ const inner = (c.bg || c.border || c.radius || c.padY || c.padX)
125
+ ? '<div style="background:' + (c.bg || 'transparent') + ';' + (c.border ? 'border:' + c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') + ';' : '') + 'border-radius:' + (c.radius || 0) + 'px;padding:' + (c.padY || 0) + 'px ' + (c.padX || 0) + 'px">\n ' + colInner(c) + '\n </div>'
126
+ : colInner(c);
127
+ return '<td width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + inner + '\n </td>';
128
+ }).join('\n ');
129
+ const cssBody = rp.layout === 'grid'
130
+ ? '<div style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
131
+ : '<div style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
132
+ const body = rp.layout && rp.layout !== 'columns'
133
+ ? cssBody
134
+ : '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>\n ' + cells + '\n </tr></table>';
135
+ const tdBg = rp.bgImage
136
+ ? 'background-color:' + (rp.bg || t.contentBg || 'transparent') + ';background-image:' + (rp.overlay ? 'linear-gradient(rgba(20,22,24,' + (rp.overlay / 100) + '),rgba(20,22,24,' + (rp.overlay / 100) + ')),' : '') + 'url(&quot;' + cssUrl(rp.bgImage) + '&quot;);background-size:' + (rp.bgSize || 'cover') + ';background-position:' + (rp.bgPos || 'center') + ';background-repeat:' + (rp.bgRepeat || 'no-repeat') + ';'
137
+ // Falls all the way through to `transparent`: a row inherits the
138
+ // content column's background, and that column may itself be unpainted
139
+ // now that it can be -- 'background:;' is not a declaration.
140
+ : 'background:' + (rp.bg || t.contentBg || 'transparent') + ';';
141
+ const tdBorder = rowBorderCss(rp)
142
+ + (rp.radius ? 'border-radius:' + rp.radius + 'px;' : '')
53
143
  + (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '')
54
144
  + ((rp.mt || rp.mr || rp.mb || rp.ml || rp.my) ? 'margin:' + rowMargin(rp) + ';' : '');
55
- return ' <tr>\n <td style="padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </td>\n </tr>';
56
- }).join('\n');
57
- return '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light dark">\n<title>' + 'Email' + '</title>\n</head>\n<body style="margin:0;padding:0;background:' + t.bg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + t.bg + ';">\n <tr><td align="center" style="padding:24px 12px;">\n <table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0" style="width:' + t.width + 'px;max-width:100%;background:' + t.contentBg + ';">\n' + rows + '\n </table>\n </td></tr>\n</table>\n</body>\n</html>';
58
- }
145
+ return ' <tr>\n <td style="padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </td>\n </tr>';
146
+ }).join('\n') + (logic.tail ? '\n ' + logic.tail : '');
147
+ // The page section -- the full-width area the content column sits on. Its
148
+ // padding is the band a mail client shows around the template, and `radius`
149
+ // is the content column's own shape. The padding used to be a hard-coded
150
+ // `24px 12px` here -- an unreachable strip around every sent template -- so
151
+ // it now comes from the document and starts at 0. A background of
152
+ // `transparent` (or any rgba value) is passed through untouched: clients
153
+ // that honour it let their own surface show through, the rest fall back to
154
+ // their default page colour.
155
+ const pageBg = t.bg || 'transparent';
156
+ const pagePad = (t.padY || t.padX) ? (t.padY || 0) + 'px ' + (t.padX || 0) + 'px' : '0';
157
+ // `overflow:hidden` is what actually clips a row's own background to the
158
+ // rounded corner; without it the first and last rows paint square over it.
159
+ const contentShape = t.radius ? 'border-radius:' + t.radius + 'px;overflow:hidden;' : '';
160
+ return '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light dark">\n<title>' + 'Email' + '</title>\n</head>\n<body style="margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n <table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0" style="width:' + t.width + 'px;max-width:100%;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>\n </td></tr>\n</table>\n</body>\n</html>';
161
+ }