'use strict'; var csso = require('csso'); /** * Generates a sequence of alphabetical characters. */ class Sequence { current; constructor() { this.current = ['a']; } next() { const sequence = this.current.join(''); let i = this.current.length - 1; while (i >= 0) { if (this.current[i] !== 'z') { this.current[i] = this.getNextChar(this.current[i]); break; } else { this.current[i] = 'a'; i--; } } if (i < 0) { this.current.unshift('a'); } return sequence; } getNextChar(char) { const charCode = char.charCodeAt(0); const nextCharCode = charCode + 1; return String.fromCharCode(nextCharCode); } } const createDocument = async (html) => { const isNode = typeof window === 'undefined' && typeof document === 'undefined'; if (isNode) { const { JSDOM } = await import('jsdom'); return new JSDOM(html).window.document; } else { return Promise.resolve(new DOMParser().parseFromString(html, 'text/html')); } }; class TaskManager { mapping; tasks; constructor() { this.tasks = []; this.mapping = { 'minify-ids': (el, context) => { if (!el.id) { return []; } const id = el.id; const { styles, idSequence, mapping: { ids }, } = context; if (new RegExp(`#${id}`).test(styles)) { let newId = ''; if (ids.has(id)) { newId = ids.get(id); } else { newId = idSequence.next(); ids.set(id, newId); } return [{ el, action: 'set-attribute', name: 'id', value: newId }]; } else { return [{ el, action: 'remove-attribute', name: 'id' }]; } }, 'minify-classes': (el, context) => { const classNames = Array.from(el.classList.values()); const { styles, classSequence, mapping: { classes }, } = context; if (classNames.length > 0 && classNames.some((className) => new RegExp(`.${className}`).test(styles))) { const newClassNames = []; classNames.forEach((className) => { let newClassName = ''; if (classes.has(className)) { newClassName = classes.get(className); } else { newClassName = classSequence.next(); classes.set(className, newClassName); } newClassNames.push(newClassName); }); return [ { el, action: 'set-attribute', name: 'class', value: newClassNames.join(' '), }, ]; } else { return [{ el, action: 'remove-attribute', name: 'class' }]; } }, 'remove-unused-attrs': (el, context, { matches } = { matches: [] }) => { const tasks = []; const { styles } = context; if (matches.length === 0) return []; el.getAttributeNames() .filter((attr) => { return matches.some((reg) => reg.test(attr) && !styles.includes(`[${attr}`)); }) .forEach((attr) => { tasks.push({ el, action: 'remove-attribute', name: attr, }); }); return tasks; }, // Dataset attrs are customized, we can safely minify them. 'minify-dataset-attrs': (el, context) => { const tasks = []; const { mapping: { dataSets }, } = context; el.getAttributeNames() .filter((attr) => attr.startsWith('data-')) .forEach((attr) => { const newAttribute = attr .split('-') .map((x) => x[0]) .flat() .join(''); // data-offset-key -> dok tasks.push({ el, action: 'remove-attribute', name: attr }); tasks.push({ el, action: 'set-attribute', name: newAttribute, value: '', }); dataSets.set(attr, newAttribute); }); return tasks; }, 'minify-styles': (el, context, { minifyStyles = true, }) => { const tasks = []; const { mapping: { ids, classes, dataSets }, } = context; // Replace id selectors. eg: #id for (const key of Array.from(ids.keys()).sort((a, b) => b.length - a.length)) { tasks.push({ el, action: 'minify-css-selector', originSelector: `#${key}`, newSelector: `#${ids.get(key)}`, }); } // Replace class selectors. eg: .class for (const key of Array.from(classes.keys()).sort((a, b) => b.length - a.length)) { tasks.push({ el, action: 'minify-css-selector', originSelector: `.${key}`, newSelector: `.${classes.get(key)}`, }); } // Replace dataset property selectors. eg: span[data-offset-key] for (const key of Array.from(dataSets.keys()).sort((a, b) => b.length - a.length)) { tasks.push({ el, action: 'minify-css-selector', originSelector: `[${key}`, newSelector: `[${dataSets.get(key)}`, }); } minifyStyles && tasks.push({ el, action: 'minify-style' }); return tasks; }, }; } add(type, el, context, // Feel free to submit PRs to avoid using any here options) { this.tasks.push(...this.mapping[type](el, context, options)); return this; } runAll = () => { const runner = (task) => { const { el } = task; switch (task.action) { case 'set-attribute': el.setAttribute(task.name, task.value); break; case 'remove-attribute': el.removeAttribute(task.name); break; case 'minify-css-selector': if (el.textContent) { el.textContent = el.textContent.replaceAll(task.originSelector, task.newSelector); } break; case 'minify-style': if (el.textContent) { el.textContent = csso.minify(el.textContent).css; } break; } }; this.tasks.forEach(runner); const ranTasks = [...this.tasks]; this.tasks = []; return ranTasks; }; } const DEFAULT_COMPOSE_OPTIONS = { minifyIds: true, minifyClasses: true, minifyDatasets: true, minifyStyles: true, removeUnusedAttrs: false, }; /** * Minify an email body * @example * const minifier = new EmailMinifier('...'); * const result = await minifier.minify(); * console.log(result.minified); */ class EmailMinifier { emailBody; constructor(emailBody) { this.emailBody = emailBody; } /** * Compose the minify tasks */ compose = (context, options) => { const taskManager = new TaskManager(); const { minifyIds, minifyClasses, minifyDatasets, minifyStyles, removeUnusedAttrs, } = { ...DEFAULT_COMPOSE_OPTIONS, ...(options ?? {}) }; const { document } = context; document.body.querySelectorAll('*').forEach((el) => { minifyIds && taskManager.add('minify-ids', el, context); minifyClasses && taskManager.add('minify-classes', el, context); removeUnusedAttrs && taskManager.add('remove-unused-attrs', el, context, { matches: removeUnusedAttrs, }); minifyDatasets && taskManager.add('minify-dataset-attrs', el, context); }); document.head.querySelectorAll('style').forEach((el) => { taskManager.add('minify-styles', el, context, { minifyStyles, }); }); return taskManager; }; async createContext() { const { emailBody } = this; const document = await createDocument(emailBody); return { originalEmailBody: emailBody, styles: Array.from(document.head.querySelectorAll('style')) .map((el) => el.textContent) .join('\n'), document, idSequence: new Sequence(), classSequence: new Sequence(), mapping: { ids: new Map(), classes: new Map(), dataSets: new Map(), }, }; } async minify(options) { const context = await this.createContext(); const taskManager = this.compose(context, options); const { originalEmailBody, document } = context; const tasks = taskManager.runAll(); if (tasks.length === 0) { return { original: originalEmailBody, minified: null, tasks, }; } return { original: originalEmailBody, minified: document.documentElement.outerHTML, tasks, }; } } exports.EmailMinifier = EmailMinifier;