@designsystemsinternational/email 0.0.2 → 0.0.4

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.
@@ -0,0 +1,362 @@
1
+ import posthtml from 'posthtml';
2
+ import removeTags from 'posthtml-remove-tags';
3
+ import removeAttributes from 'posthtml-remove-attributes';
4
+ import matchHelper from 'posthtml-match-helper';
5
+ import colorConvert from 'color-convert';
6
+ import unescape from 'lodash.unescape';
7
+ import JSZip from 'jszip';
8
+ import { saveAs } from 'file-saver';
9
+
10
+ /**
11
+ * Generates a deterministic random string based on the input string.
12
+ *
13
+ * @param {string} str Input string
14
+ * @return {string} hash of str
15
+ */
16
+ const hashFromString = (str) => {
17
+ let hash = 0;
18
+ for (let i = 0; i < str.length; i++) {
19
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
20
+ }
21
+ return hash.toString();
22
+ };
23
+
24
+ /**
25
+ * Formats a string to a filename by turning it into kebab case
26
+ *
27
+ * @param {string} text
28
+ * @return {string} formatted filename
29
+ */
30
+ const formatFilename = (text) => {
31
+ return text
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9-]+/g, ' ')
34
+ .trim()
35
+ .replace(/\s+/g, '-');
36
+ };
37
+
38
+ /**
39
+ * Converts all rgb defined colors with their hex equivalent
40
+ * as some email clients only support hex color format.
41
+ */
42
+ const convertRgbToHex = () => {
43
+ return (tree) => {
44
+ tree.match(matchHelper('[style]'), (node) => {
45
+ let styleBlock = node.attrs.style;
46
+ styleBlock = styleBlock.replace(
47
+ /rgb\(\s*\d{1,3},\s*\d{1,3},\s*\d{1,3}\)/g,
48
+ (match) => {
49
+ const values = match
50
+ .replace(/[rgb() ]/g, '')
51
+ .split(',')
52
+ .map((n) => parseInt(n));
53
+ return '#' + colorConvert.rgb.hex(values);
54
+ },
55
+ );
56
+ node.attrs.style = styleBlock;
57
+ return node;
58
+ });
59
+
60
+ return tree;
61
+ };
62
+ };
63
+
64
+ /**
65
+ * Prepares HTML by only returning the body tag, to make sure
66
+ */
67
+ const onlyReturnBody = () => {
68
+ return (tree) => {
69
+ tree.match({ tag: 'body' }, (node) => {
70
+ return node.content;
71
+ });
72
+
73
+ return tree;
74
+ };
75
+ };
76
+
77
+ /**
78
+ * Rewrites the markup by turning all conditional comments returned from the
79
+ * <ConditionalComment> component into true conditional comments that can be
80
+ * picked up by Outlook.
81
+ */
82
+ const injectConditionalComments = () => {
83
+ return (tree) => {
84
+ tree.match(matchHelper('[data-conditional-comment]'), (node) => {
85
+ const comment = node.attrs['data-conditional-comment'];
86
+
87
+ // Remove HTML comment delimiters
88
+ const cleanedComment = unescape(comment).replace(/<!--|-->/g, '');
89
+
90
+ // Remove Line breaks from comment
91
+ const cleanedCommentLines = cleanedComment.split('\n').join('');
92
+
93
+ node = `<!--${cleanedCommentLines}-->`;
94
+ return node;
95
+ });
96
+ return tree;
97
+ };
98
+ };
99
+
100
+ /**
101
+ * Rewrites the markup by adding all styles defined in the data-mso-style
102
+ * attribute to the <style> tag. This is needed because the mso styles are
103
+ * non-standard CSS, so they cannot be added to React's style tag.
104
+ */
105
+ const injectMsoStyles = () => {
106
+ return (tree) => {
107
+ tree.match(matchHelper('[data-mso-style]'), (node) => {
108
+ if (node.attrs?.style == null) node.attrs.style = '';
109
+ node.attrs.style += node.attrs['data-mso-style'];
110
+ node.attrs['data-mso-style'] = null;
111
+ return node;
112
+ });
113
+ return tree;
114
+ };
115
+ };
116
+
117
+ /**
118
+ * Collects all inline styles defined in the components, de-dedupes them and
119
+ * moves them to a single unified style tag in the head of the document.
120
+ */
121
+ const moveInlineStylesToHead = () => {
122
+ let styles = {};
123
+
124
+ return (tree) => {
125
+ tree.match({ tag: 'style' }, (node) => {
126
+ if (!node.content) return '';
127
+ const styleBlock = node.content[0].trim();
128
+ const hash = hashFromString(styleBlock);
129
+
130
+ if (styleBlock) {
131
+ styles[hash] = styleBlock;
132
+ }
133
+
134
+ // Remove the inline style node
135
+ return '';
136
+ });
137
+
138
+ tree.match({ tag: 'head' }, (node) => {
139
+ node.content.push({
140
+ tag: 'style',
141
+ content: Object.values(styles).join(''),
142
+ });
143
+
144
+ return node;
145
+ });
146
+
147
+ return tree;
148
+ };
149
+ };
150
+
151
+ const prepareHtmlFactory = (plugins) => {
152
+ const htmlProcessor = posthtml();
153
+
154
+ plugins.forEach((plugin) => {
155
+ htmlProcessor.use(plugin);
156
+ });
157
+
158
+ return async (body, template = (str) => str) => {
159
+ const content = template(body);
160
+
161
+ const processed = await htmlProcessor.process(content);
162
+
163
+ return processed.html;
164
+ };
165
+ };
166
+
167
+ /**
168
+ * This default pipeline removes all script and link tags from the HTML,
169
+ * moves all inline styles to the head and wraps everything in the email template.
170
+ *
171
+ * @param {string} body - the html body
172
+ * @param {function} template - the template function, defaults to no template
173
+ */
174
+ const defaultPipeline = prepareHtmlFactory([
175
+ onlyReturnBody(),
176
+ removeTags({ tags: ['script', 'link'] }),
177
+ convertRgbToHex(),
178
+ moveInlineStylesToHead(),
179
+ injectMsoStyles(),
180
+ injectConditionalComments(),
181
+ removeAttributes(['data-reactid', 'data-reactroot']),
182
+ ]);
183
+
184
+ const BASE64_REGEX = /data:image\/([a-zA-Z]*);base64,(.*)/;
185
+
186
+ // Browser packacking factory
187
+
188
+ /**
189
+ * Turns base64 inlined images into image buffers and updates their src
190
+ * attribute accordingly. Returns an object containing the updated html and
191
+ * an array of image buffers. Different adapters can then hook into this to
192
+ * process the email into a ZIP archive or pushing it to an API.
193
+ *
194
+ * @param {string} body - the prepared html body
195
+ * @return {object} the processed html and base64 strings for all images
196
+ */
197
+ const preparePackageFactory =
198
+ ({ urlPrefix = '', handlePackage }) =>
199
+ async (body, opts = {}) => {
200
+ let counter = 0;
201
+ const images = [];
202
+
203
+ const processed = await posthtml()
204
+ .use((tree) => {
205
+ tree.match({ tag: 'img' }, (node) => {
206
+ const { src, alt } = node.attrs;
207
+
208
+ if (BASE64_REGEX.test(src)) {
209
+ const [_, ext, data] = src.match(
210
+ /data:image\/([a-zA-Z]*);base64,(.*)/,
211
+ );
212
+
213
+ const hash = hashFromString(`${data}`);
214
+ console.log(hash);
215
+
216
+ const filename = alt
217
+ ? `${formatFilename(alt)}-text-${hash}.${ext}`
218
+ : `image-${counter}.${ext}`;
219
+
220
+ node.attrs.src = `${urlPrefix}${filename}`;
221
+
222
+ images.push({
223
+ filename,
224
+ buffer: data,
225
+ });
226
+
227
+ counter++;
228
+ }
229
+ return node;
230
+ });
231
+
232
+ return tree;
233
+ })
234
+ .process(body);
235
+
236
+ return await handlePackage(
237
+ {
238
+ html: processed.html,
239
+ images,
240
+ },
241
+ opts,
242
+ );
243
+ };
244
+
245
+ const makeDefaultTemplate =
246
+ ({ lang = 'en-US', dir = 'ltr' } = {}) =>
247
+ (body) =>
248
+ `<!doctype html>
249
+ <html
250
+ lang="${lang}"
251
+ xml:lang="${lang}"
252
+ dir="${dir}"
253
+ xmlns="http://www.w3.org/1999/xhtml"
254
+ xmlns:v="urn:schemas-microsoft-com:vml"
255
+ xmlns:o="urn:schemas-microsoft-com:office:office"
256
+ >
257
+ <head>
258
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
259
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
260
+ <title>Chief</title>
261
+ <!--[if mso]>
262
+ <style type="text/css">
263
+ table {
264
+ border-collapse: collapse;
265
+ border: 0;
266
+ border-spacing: 0;
267
+ margin: 0;
268
+ mso-table-lspace: 0pt !important;
269
+ mso-table-rspace: 0pt !important;
270
+ }
271
+
272
+ img {
273
+ display: block !important;
274
+ }
275
+
276
+ div, td {padding:0;}
277
+
278
+ div {margin:0 !important;}
279
+
280
+ .notMso {
281
+ display: none;
282
+ mso-hide: all;
283
+ }
284
+ </style>
285
+ <noscript>
286
+ <xml>
287
+ <o:OfficeDocumentSettings>
288
+ <o:PixelsPerInch>96</o:PixelsPerInch>
289
+ </o:OfficeDocumentSettings>
290
+ </xml>
291
+ </noscript>
292
+ <![endif]-->
293
+ <meta name="x-apple-disable-message-reformatting" />
294
+ <meta httpEquiv="Content-Type" content="text/html; charset=utf-8" />
295
+ <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
296
+ <meta name="viewport" content="width=device-width" />
297
+ <meta name="color-scheme" content="light" />
298
+ <meta name="supported-color-schemes" content="light" />
299
+
300
+ <meta name="format-detection" content="telephone=no">
301
+ <meta name="format-detection" content="date=no">
302
+ <meta name="format-detection" content="address=no">
303
+ <meta name="format-detection" content="email=no">
304
+
305
+ <style>
306
+ a[x-apple-data-detectors] {
307
+ color: inherit !important;
308
+
309
+ font-size: inherit !important;
310
+ font-family: inherit !important;
311
+ font-weight: inherit !important;
312
+ line-height: inherit !important;
313
+ text-decoration: inherit !important;
314
+ }
315
+
316
+ #MessageViewBody a:not([style]) {
317
+ color: inherit !important;
318
+ font-size: inherit !important;
319
+ font-family: inherit !important;
320
+ font-weight: inherit !important;
321
+ line-height: inherit !important;
322
+ text-decoration: inherit !important;
323
+ }
324
+ </style>
325
+ </head>
326
+ <body class="body" style="padding:0;">
327
+ ${body}
328
+ </body>
329
+ </html>`;
330
+
331
+ const defaultTemplate = makeDefaultTemplate();
332
+
333
+ const defaultConfig = {
334
+ name: 'email',
335
+ };
336
+
337
+ /**
338
+ * @param {string} body - the prepared html body
339
+ * @return {function} A function that can be called to download the email as a ZIP archive
340
+ */
341
+ const packageToZip = preparePackageFactory({
342
+ urlPrefix: '',
343
+ handlePackage: ({ html, images }, opts) => {
344
+ const config = { ...defaultConfig, ...opts };
345
+
346
+ const zip = new JSZip();
347
+ const files = zip.folder(config.name);
348
+
349
+ files.file('index.html', html);
350
+ images.forEach(({ filename, buffer }) => {
351
+ files.file(filename, buffer, { base64: true });
352
+ });
353
+
354
+ return async () => {
355
+ files.generateAsync({ type: 'blob' }).then((content) => {
356
+ saveAs(content, `${formatFilename(config.name)}.zip`);
357
+ });
358
+ };
359
+ },
360
+ });
361
+
362
+ export { defaultTemplate, makeDefaultTemplate, packageToZip, defaultPipeline as prepareHtml, prepareHtmlFactory, preparePackageFactory };