@seliseblocks/mailcraft 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/DOCS.md +238 -10
  2. package/README.md +11 -3
  3. package/README.md.txt +134 -0
  4. package/dist/mailcraft-editor.bundle.js +51 -39
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/order-confirmed.html +80 -80
  7. package/examples/vanilla.html +234 -22
  8. package/package.json +7 -2
  9. package/src/core/assets.js +10 -15
  10. package/src/core/binder.js +120 -118
  11. package/src/core/blocks.js +14 -1
  12. package/src/core/css-cascade.js +117 -117
  13. package/src/core/editor-core.js +1530 -1492
  14. package/src/core/export.js +142 -55
  15. package/src/core/i18n/ar.js +219 -177
  16. package/src/core/i18n/bg.js +196 -152
  17. package/src/core/i18n/bn.js +218 -176
  18. package/src/core/i18n/ca.js +196 -152
  19. package/src/core/i18n/cs.js +196 -152
  20. package/src/core/i18n/da.js +196 -152
  21. package/src/core/i18n/de-CH.js +196 -152
  22. package/src/core/i18n/de.js +196 -152
  23. package/src/core/i18n/dz.js +221 -179
  24. package/src/core/i18n/el.js +196 -152
  25. package/src/core/i18n/en.js +3 -10
  26. package/src/core/i18n/es.js +196 -152
  27. package/src/core/i18n/et.js +196 -152
  28. package/src/core/i18n/fi.js +196 -152
  29. package/src/core/i18n/fr.js +196 -152
  30. package/src/core/i18n/hr.js +196 -152
  31. package/src/core/i18n/hu.js +196 -152
  32. package/src/core/i18n/index.js +83 -83
  33. package/src/core/i18n/it.js +196 -152
  34. package/src/core/i18n/lt.js +196 -152
  35. package/src/core/i18n/lv.js +196 -152
  36. package/src/core/i18n/nb.js +196 -152
  37. package/src/core/i18n/nl.js +196 -152
  38. package/src/core/i18n/pl.js +196 -152
  39. package/src/core/i18n/pt.js +196 -152
  40. package/src/core/i18n/ro.js +196 -152
  41. package/src/core/i18n/ru.js +196 -152
  42. package/src/core/i18n/sk.js +196 -152
  43. package/src/core/i18n/sl.js +196 -152
  44. package/src/core/i18n/sv.js +196 -152
  45. package/src/core/i18n/tables.js +50 -50
  46. package/src/core/i18n/tr.js +196 -152
  47. package/src/core/i18n/uk.js +196 -152
  48. package/src/core/icons.js +237 -235
  49. package/src/core/ids.js +1 -1
  50. package/src/core/import-html.js +1025 -959
  51. package/src/core/layout-style.js +100 -100
  52. package/src/core/parse.js +10 -10
  53. package/src/core/placeholder.js +15 -15
  54. package/src/core/sanitize.js +141 -141
  55. package/src/core/storage-limits.js +184 -184
  56. package/src/core/storage.js +85 -85
  57. package/src/core/theme.js +1 -1
  58. package/src/core/variables.js +11 -11
  59. package/src/index.js +9 -9
  60. package/src/mailcraft-editor.js +26 -14
  61. package/src/render/block-body.js +49 -6
  62. package/src/render/canvas.js +31 -2
  63. package/src/render/fields.js +602 -588
  64. package/src/render/focus-preserve.js +158 -158
  65. package/src/render/rte.js +241 -212
  66. package/src/render/screenshot.js +132 -132
  67. package/src/render/story.js +415 -415
  68. package/src/render/style.js +8 -0
  69. package/types/index.d.ts +419 -0
@@ -1,184 +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
- }
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,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, 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
+ }
package/src/core/theme.js CHANGED
@@ -1 +1 @@
1
- export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
1
+ export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, 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';