@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,184 +1,189 @@
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
- }
1
+ /**
2
+ * Upload validation -- mechanism only, no policy.
3
+ *
4
+ * Sizes and counts stay undefaulted: what an email template may carry depends
5
+ * on the sending platform (ESP attachment caps) and the host's own product
6
+ * rules, so those numbers are the host's to set -- `editor.storageLimits`, or
7
+ * `limits` on the provider. Formats are the one axis with a default: every
8
+ * image type the sniffer can name is allowed unless the host narrows `accept`.
9
+ * With a provider wired and no `maxBytes` declared, uploads are refused rather
10
+ * than waved through.
11
+ *
12
+ * Checks run *before* the provider is called, so a rejected file never reaches
13
+ * the backend. That matters for any store where minting an upload URL also
14
+ * creates the file record: validating afterwards would leave an orphan behind
15
+ * for every rejection.
16
+ *
17
+ * Types are decided by sniffing the leading bytes, not by trusting `file.type`:
18
+ * the browser fills that in from the file extension, so renaming `payload.svg`
19
+ * to `photo.png` is enough to walk a script-bearing document past a MIME check
20
+ * and into the editor's own DOM, where the library preview renders it.
21
+ */
22
+
23
+ import { KB } from './assets.js';
24
+
25
+ const ascii = (b, at, s) => s.split('').every((c, i) => b[at + i] === c.charCodeAt(0));
26
+
27
+ /**
28
+ * The leading bytes of every raster format a mail client might plausibly be
29
+ * asked to show, plus the ones it can't -- knowing a file is AVIF is what lets
30
+ * the rejection say "AVIF" instead of "unsupported".
31
+ */
32
+ function sniff(b) {
33
+ if (b.length < 12) return null;
34
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
35
+ if (b[0] === 0x89 && ascii(b, 1, 'PNG')) return 'image/png';
36
+ if (ascii(b, 0, 'GIF8')) return 'image/gif';
37
+ if (ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP')) return 'image/webp';
38
+ if (ascii(b, 0, 'BM')) return 'image/bmp';
39
+ if ((ascii(b, 0, 'II') && b[2] === 0x2a && b[3] === 0) || (ascii(b, 0, 'MM') && b[2] === 0 && b[3] === 0x2a)) return 'image/tiff';
40
+ if (b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0) return 'image/x-icon';
41
+ if (ascii(b, 4, 'ftyp')) {
42
+ const brand = String.fromCharCode(b[8], b[9], b[10], b[11]);
43
+ if (brand === 'avif' || brand === 'avis') return 'image/avif';
44
+ return 'image/heic';
45
+ }
46
+ // Markup: an SVG may open with a BOM, an XML declaration, a doctype or a
47
+ // comment before the root element, so the reliable tell is "text that starts
48
+ // with '<'", not a literal "<svg" at offset zero.
49
+ let i = 0;
50
+ if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) i = 3;
51
+ while (i < b.length && (b[i] === 0x20 || b[i] === 0x09 || b[i] === 0x0a || b[i] === 0x0d)) i++;
52
+ if (b[i] === 0x3c) return 'image/svg+xml';
53
+ return null;
54
+ }
55
+
56
+ /** First bytes of a file. `Blob.arrayBuffer` where it exists, FileReader where it doesn't. */
57
+ function head(file) {
58
+ const slice = file.slice(0, 32);
59
+ if (typeof slice.arrayBuffer === 'function') {
60
+ return slice.arrayBuffer().then((buf) => new Uint8Array(buf)).catch(() => null);
61
+ }
62
+ return new Promise((resolve) => {
63
+ const fr = new FileReader();
64
+ fr.onload = () => resolve(new Uint8Array(fr.result));
65
+ fr.onerror = () => resolve(null);
66
+ fr.readAsArrayBuffer(slice);
67
+ });
68
+ }
69
+
70
+ /** Decoded pixel dimensions, or `null` when the browser can't decode it -- which is itself a reason to refuse the file. */
71
+ function probe(file) {
72
+ return new Promise((resolve) => {
73
+ const url = URL.createObjectURL(file);
74
+ const img = new Image();
75
+ const done = (v) => { URL.revokeObjectURL(url); resolve(v); };
76
+ img.onload = () => done({ w: img.naturalWidth || img.width, ht: img.naturalHeight || img.height });
77
+ img.onerror = () => done(null);
78
+ img.src = url;
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Strips directory separators, control characters and anything a storage
84
+ * backend is likely to treat as path syntax, and caps the length. A name
85
+ * arrives from the user's disk and ends up in a URL.
86
+ */
87
+ export function sanitizeName(name) {
88
+ const clean = String(name || 'file')
89
+ .replace(/[\\/]/g, '-')
90
+ .replace(/[<>:"|?*]/g, '')
91
+ .split('').filter((c) => c.charCodeAt(0) > 31).join('')
92
+ .trim()
93
+ .replace(/^\.+/, '') || 'file';
94
+ if (clean.length <= 120) return clean;
95
+ const dot = clean.lastIndexOf('.');
96
+ const ext = dot > 0 && clean.length - dot <= 8 ? clean.slice(dot) : '';
97
+ return clean.slice(0, 120 - ext.length) + ext;
98
+ }
99
+
100
+ /** The host has to have said how big a file may be; the format list is optional and defaults to all of these. */
101
+ export function limitsProblem(limits) {
102
+ if (!limits) return 'storage.errNoLimits';
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
+ // An omitted or empty `accept` means every image type the sniffer can name,
125
+ // not "nothing" -- a host that wants a narrower list has to say so, but the
126
+ // starting point is that an image is uploadable.
127
+ const accept = Array.isArray(limits.accept) && limits.accept.length
128
+ ? limits.accept.map((m) => String(m).toLowerCase())
129
+ : null;
130
+ const max = Number(limits.maxBytes);
131
+ const maxW = Number(limits.maxWidth) || 0;
132
+ const maxH = Number(limits.maxHeight) || 0;
133
+ const perDrop = Number(limits.maxFilesPerDrop) || 0;
134
+
135
+ const queue = perDrop && files.length > perDrop ? files.slice(0, perDrop) : files;
136
+ if (queue.length < files.length) {
137
+ files.slice(queue.length).forEach((f) => rejected.push({ name: f.name, key: 'storage.errTooMany', params: { max: perDrop } }));
138
+ }
139
+
140
+ for (const file of queue) {
141
+ const name = sanitizeName(file.name);
142
+
143
+ if (file.size > max) {
144
+ rejected.push({ name, key: 'storage.errTooLarge', params: { name, size: KB(file.size), max: KB(max) } });
145
+ continue;
146
+ }
147
+
148
+ const bytes = await head(file);
149
+ const type = bytes && sniff(bytes);
150
+ if (!type) {
151
+ rejected.push({ name, key: 'storage.errUnreadable', params: { name } });
152
+ continue;
153
+ }
154
+ if (accept && !accept.includes(type)) {
155
+ rejected.push({ name, key: 'storage.errFormat', params: { name, type: type.replace(/^image\//, '').toUpperCase() } });
156
+ continue;
157
+ }
158
+ // Listing SVG in `accept` is not on its own enough. It is the one image
159
+ // type that is also a script host, and it renders inside the editor's own
160
+ // shadow root the moment it appears as a library tile -- so a host that
161
+ // genuinely wants it has to say so twice, and can never enable it by
162
+ // pasting a permissive MIME list.
163
+ if (type === 'image/svg+xml' && !limits.allowSvg) {
164
+ rejected.push({ name, key: 'storage.errSvg', params: { name } });
165
+ continue;
166
+ }
167
+
168
+ let dims = { w: 0, ht: 0 };
169
+ if (type !== 'image/svg+xml') {
170
+ const measured = await probe(file);
171
+ if (!measured) { rejected.push({ name, key: 'storage.errUnreadable', params: { name } }); continue; }
172
+ dims = measured;
173
+ if ((maxW && dims.w > maxW) || (maxH && dims.ht > maxH)) {
174
+ rejected.push({ name, key: 'storage.errDimensions', params: { name, w: dims.w, ht: dims.ht, maxW: maxW || dims.w, maxH: maxH || dims.ht } });
175
+ continue;
176
+ }
177
+ }
178
+
179
+ accepted.push({ file, name, type, size: file.size, w: dims.w, ht: dims.ht });
180
+ }
181
+
182
+ return { accepted, rejected };
183
+ }
184
+
185
+ /** An `accept` attribute for the file picker, so the OS dialog greys out what validation would refuse anyway. */
186
+ export function acceptAttribute(limits) {
187
+ if (!limits || !Array.isArray(limits.accept) || !limits.accept.length) return 'image/*';
188
+ return limits.accept.join(',');
189
+ }
@@ -1,85 +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
- }
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. Omitted or empty means every image type the validator recognizes.
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
+ }
package/src/core/theme.js CHANGED
@@ -1 +1,15 @@
1
- export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
1
+ /**
2
+ * The document's page-level model. `bg` paints the full-width area the email
3
+ * sits on (what a mail client shows around the content column), `contentBg`
4
+ * paints the content column itself, and `padY`/`padX`/`radius` shape the gap
5
+ * between the two -- the band that is otherwise an unstyleable strip around
6
+ * every template.
7
+ *
8
+ * They default to 0 rather than to the 24px/12px band the exporter used to
9
+ * hard-code: that band was in every sent template with no way to reach it,
10
+ * which is the strip around the content column that looked like a rendering
11
+ * fault. Now it is off unless a document asks for it. Both background keys
12
+ * accept the literal `transparent` (and any rgba()/#rrggbbaa value) as well
13
+ * as a hex colour.
14
+ */
15
+ export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, padY: 0, padX: 0, radius: 0, font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
@@ -1,11 +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' };
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' };
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
- export { MailCraftEditor } from './mailcraft-editor.js';
2
- export { createEditor, isReady } from './create-editor.js';
3
- export { EditorCore } from './core/editor-core.js';
4
- export { renderDoc } from './render/canvas.js';
5
- export { BLOCKS, GROUPS, LAYOUTS, PALETTE } from './core/blocks.js';
6
- export { createTranslator, defineMessages, missingKeys, LOCALES, isRtl, EN, MESSAGE_KEYS } from './core/i18n/index.js';
7
- export { LOCALE_TABLES } from './core/i18n/tables.js';
8
- export { ALL_FOLDER_ID, normalizeAsset, resolveLimits } from './core/storage.js';
9
- export { validateFiles, sanitizeName, acceptAttribute, limitsProblem } from './core/storage-limits.js';
1
+ export { MailCraftEditor } from './mailcraft-editor.js';
2
+ export { createEditor, isReady } from './create-editor.js';
3
+ export { EditorCore } from './core/editor-core.js';
4
+ export { renderDoc } from './render/canvas.js';
5
+ export { BLOCKS, GROUPS, LAYOUTS, PALETTE } from './core/blocks.js';
6
+ export { createTranslator, defineMessages, missingKeys, LOCALES, isRtl, EN, MESSAGE_KEYS } from './core/i18n/index.js';
7
+ export { LOCALE_TABLES } from './core/i18n/tables.js';
8
+ export { ALL_FOLDER_ID, normalizeAsset, resolveLimits } from './core/storage.js';
9
+ export { validateFiles, sanitizeName, acceptAttribute, limitsProblem } from './core/storage-limits.js';