@maizzle/framework
Version:
Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.
207 lines (206 loc) • 7.12 kB
JavaScript
import { parse } from "../utils/ast/parser.js";
import { walk } from "../utils/ast/walker.js";
import { serialize } from "../utils/ast/serializer.js";
import "../utils/ast/index.js";
import { defu as defu$1 } from "defu";
import safeParser from "postcss-safe-parser";
import { selectAll } from "css-select";
import { comb } from "email-comb";
//#region src/transformers/purgeCss.ts
const DEFAULT_SAFELIST = [
"*body*",
".gmail*",
".apple*",
".ios*",
".ox-*",
".outlook*",
"[data-ogs*",
".bloop_container",
".Singleton",
".unused",
".moz-text-html",
".mail-detail-content",
"*edo*",
"#*",
".\\&*",
".lang*"
];
const DEFAULT_OPTIONS = {
backend: [{
heads: "{{",
tails: "}}"
}, {
heads: "{%",
tails: "%}"
}],
whitelist: [...DEFAULT_SAFELIST]
};
/**
* Remove unused CSS from an HTML string.
*
* Uses `email-comb` together with a DOM-aware deep-purge step to strip
* CSS selectors and class/id references that are not matched anywhere
* in the document body.
*
* @param html HTML string to transform.
* @param options Email-comb options plus a Maizzle `safelist`.
* @returns The transformed HTML string.
*
* @example
* import { purgeCss } from '@maizzle/framework'
*
* const out = purgeCss('<style>.a{}.b{}</style><p class="a">x</p>', {
* safelist: ['.keep'],
* })
*/
function purgeCss(html, options = {}) {
return serialize(purgeCssDom(parse(html), options));
}
/**
* DOM-form of {@link purgeCss} used by the internal transformer
* pipeline. Takes a parsed DOM, returns a parsed DOM — avoids redundant
* serialize/parse round-trips when chained with other transformers.
*/
function purgeCssDom(dom, options = {}) {
const userSafelist = Array.isArray(options.safelist) ? options.safelist : [];
const { safelist: _discard, ...restUserOptions } = options;
/**
* Merge user options on top of defaults. defu merges objects deeply;
* for arrays it appends user values. We want the user safelist
* appended to the default safelist, so we build whitelist
* manually.
*/
const combOptions = defu$1({
...restUserOptions,
whitelist: [...DEFAULT_SAFELIST, ...userSafelist]
}, DEFAULT_OPTIONS);
/**
* Deep purge first: DOM-aware selector removal via PostCSS + css-select.
* Runs before email-comb so that email-comb can clean up orphaned
* classes in HTML attributes left behind by removed CSS rules.
*/
const safelist = [...DEFAULT_SAFELIST, ...userSafelist];
dom = deepPurge(dom, safelist);
/**
* Shield embed style tags from email-comb. Comb has no skip option,
* so it strips CSS comments and drops class refs it can't match
* against visible CSS. Swap each embed tag's body for a unique
* stub rule (`.maizzle-keep-N{}`) so comb keeps the tag, then
* whitelist that stub plus every selector from the original
* CSS so comb leaves matching refs alone elsewhere — and
* finally restore the original CSS once comb has run.
*/
const stash = [];
const extraWhitelist = [];
walk(dom, (node) => {
const el = node;
if (el.name !== "style" || !el.attribs) return;
if (!("embed" in el.attribs) && !("data-embed" in el.attribs) && !("amp-custom" in el.attribs)) return;
const textNode = el.children?.find((c) => c.type === "text");
if (!textNode?.data) return;
const token = `.maizzle-keep-${stash.length}`;
extraWhitelist.push(token);
for (const m of textNode.data.matchAll(/(?<![\w-])[.#][a-zA-Z_][\w-]*/g)) extraWhitelist.push(m[0]);
stash.push({
token,
original: textNode.data,
textNode
});
textNode.data = `${token}{}`;
});
if (extraWhitelist.length) combOptions.whitelist = [...combOptions.whitelist ?? [], ...extraWhitelist];
const { result } = comb(serialize(dom), combOptions);
/**
* Comb returns a fresh string, so we work off the post-parse tree:
* find each embed style tag whose body still starts with the stub
* token we planted earlier and swap the original CSS back in.
*/
let purgedDom = parse(result);
if (stash.length) walk(purgedDom, (node) => {
const el = node;
if (el.name !== "style" || !el.attribs) return;
if (!("embed" in el.attribs) && !("data-embed" in el.attribs) && !("amp-custom" in el.attribs)) return;
const textNode = el.children?.find((c) => c.type === "text");
if (!textNode?.data) return;
const trimmed = textNode.data.trim();
const match = stash.find((s) => trimmed === `${s.token}{}` || trimmed.startsWith(`${s.token}{`));
if (match) textNode.data = match.original;
});
/**
* Clean up data-embed/embed attributes — no longer needed after purging.
* `amp-custom` stays as-is (it's the user-authored AMP4Email attr).
*/
walk(purgedDom, (node) => {
const el = node;
if (el.name === "style" && el.attribs) {
delete el.attribs["data-embed"];
delete el.attribs.embed;
}
});
return purgedDom;
}
/**
* Deep purge: uses PostCSS to parse CSS in non-embedded style tags,
* then checks each selector against the DOM with css-select.
* Removes rules where no selector matches any element.
*/
function isSafelisted(selector, safelist) {
return safelist.some((pattern) => {
if (pattern.startsWith("*") && pattern.endsWith("*")) return selector.includes(pattern.slice(1, -1));
if (pattern.endsWith("*")) return selector.startsWith(pattern.slice(0, -1));
if (pattern.startsWith("*")) return selector.endsWith(pattern.slice(1));
return selector === pattern;
});
}
function deepPurge(dom, safelist) {
walk(dom, (node) => {
const el = node;
if (el.name !== "style" || !el.attribs) return;
if ("data-embed" in el.attribs || "embed" in el.attribs || "amp-custom" in el.attribs) return;
const textNode = el.children?.find((c) => c.type === "text");
if (!textNode?.data?.trim()) return;
const root = safeParser(textNode.data);
root.walkRules((rule) => {
/**
* Skip rules inside @media or other at-rules — those may target
* states we can't match statically (hover, responsive, etc.).
*/
if (rule.parent?.type === "atrule") return;
const selectors = rule.selectors ?? [rule.selector];
const matched = selectors.filter((sel) => {
if (isSafelisted(sel, safelist)) return true;
/**
* Skip pseudo-classes/elements that can't be matched statically.
* Functional pseudos like :not(), :is(), :where(), :has() are
* matchable by css-select, so we only skip dynamic/state
* ones.
*/
if (/::[\w-]/.test(sel)) return true;
if (/(?<!:):(?!not\b|is\b|where\b|has\b)[\w-]/.test(sel.replace(/\\./g, ""))) return true;
try {
return selectAll(sel, dom).length > 0;
} catch {
return true;
}
});
if (matched.length === 0) rule.remove();
else if (matched.length < selectors.length) rule.selectors = matched;
});
root.walkAtRules((atRule) => {
if (atRule.nodes?.length === 0) atRule.remove();
});
const purgedCss = root.toString();
if (purgedCss.trim()) textNode.data = purgedCss;
else {
const parent = el.parent;
if (parent && "children" in parent) {
const idx = parent.children.indexOf(el);
if (idx !== -1) parent.children.splice(idx, 1);
}
}
});
return dom;
}
//#endregion
export { purgeCss, purgeCssDom };
//# sourceMappingURL=purgeCss.js.map