@seliseblocks/mailcraft 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS.md +240 -12
- package/README.md +11 -3
- package/README.md.txt +134 -0
- package/dist/mailcraft-editor.bundle.js +51 -39
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/order-confirmed.html +80 -80
- package/examples/vanilla.html +242 -23
- package/package.json +7 -2
- package/src/core/assets.js +10 -15
- package/src/core/binder.js +120 -118
- package/src/core/blocks.js +14 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +1530 -1492
- package/src/core/export.js +142 -55
- package/src/core/i18n/ar.js +219 -177
- package/src/core/i18n/bg.js +196 -152
- package/src/core/i18n/bn.js +218 -176
- package/src/core/i18n/ca.js +196 -152
- package/src/core/i18n/cs.js +196 -152
- package/src/core/i18n/da.js +196 -152
- package/src/core/i18n/de-CH.js +196 -152
- package/src/core/i18n/de.js +196 -152
- package/src/core/i18n/dz.js +221 -179
- package/src/core/i18n/el.js +196 -152
- package/src/core/i18n/en.js +3 -10
- package/src/core/i18n/es.js +196 -152
- package/src/core/i18n/et.js +196 -152
- package/src/core/i18n/fi.js +196 -152
- package/src/core/i18n/fr.js +196 -152
- package/src/core/i18n/hr.js +196 -152
- package/src/core/i18n/hu.js +196 -152
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +196 -152
- package/src/core/i18n/lt.js +196 -152
- package/src/core/i18n/lv.js +196 -152
- package/src/core/i18n/nb.js +196 -152
- package/src/core/i18n/nl.js +196 -152
- package/src/core/i18n/pl.js +196 -152
- package/src/core/i18n/pt.js +196 -152
- package/src/core/i18n/ro.js +196 -152
- package/src/core/i18n/ru.js +196 -152
- package/src/core/i18n/sk.js +196 -152
- package/src/core/i18n/sl.js +196 -152
- package/src/core/i18n/sv.js +196 -152
- package/src/core/i18n/tables.js +50 -50
- package/src/core/i18n/tr.js +196 -152
- package/src/core/i18n/uk.js +196 -152
- package/src/core/icons.js +237 -235
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +1025 -959
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +141 -141
- package/src/core/storage-limits.js +184 -184
- package/src/core/storage.js +85 -85
- package/src/core/theme.js +1 -1
- package/src/core/variables.js +11 -11
- package/src/index.js +9 -9
- package/src/mailcraft-editor.js +26 -14
- package/src/render/block-body.js +49 -6
- package/src/render/canvas.js +31 -2
- package/src/render/fields.js +602 -588
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +241 -212
- package/src/render/screenshot.js +132 -132
- package/src/render/story.js +415 -415
- package/src/render/style.js +8 -0
- package/types/index.d.ts +419 -0
|
@@ -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
|
+
}
|
package/src/core/storage.js
CHANGED
|
@@ -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' });
|
package/src/core/variables.js
CHANGED
|
@@ -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';
|