UNPKG

@maizzle/framework

Version:

Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.

185 lines (184 loc) 6.63 kB
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 { defaultTags, isAbsoluteUrl, processSrcset } from "../utils/url.js"; import postcss from "postcss"; import safeParser from "postcss-safe-parser"; import valueParser from "postcss-value-parser"; //#region src/transformers/base.ts const sourceAttributes = [ "src", "href", "srcset", "poster", "background", "data" ]; /** * Convert the shared `defaultTags` (tag → string[]) into the richer format * the transformer needs (tag → Record<attr, true>). */ const defaultTagConfig = Object.fromEntries(Object.entries(defaultTags).map(([tag, attrs]) => [tag, Object.fromEntries(attrs.map((attr) => [attr, true]))])); const postcssBaseUrl = (opts) => { return { postcssPlugin: "postcss-base-url", Declaration(decl) { if (!decl.value.includes("url(")) return; const parsed = valueParser(decl.value); let changed = false; parsed.walk((node) => { if (node.type !== "function" || node.value !== "url") return; const urlNode = node.nodes[0]; if (!urlNode) return; if (isAbsoluteUrl(urlNode.value)) return; urlNode.value = opts.url + urlNode.value; changed = true; }); if (changed) decl.value = parsed.toString(); } }; }; postcssBaseUrl.postcss = true; function processCss(css, url) { const { css: result } = postcss([postcssBaseUrl({ url })]).process(css, { parser: safeParser, from: void 0 }); return result; } function processInlineStyle(style, url) { const { css } = postcss([postcssBaseUrl({ url })]).process(`a{${style}}`, { parser: safeParser, from: void 0 }); return css.slice(css.indexOf("{") + 1, css.lastIndexOf("}")).trim(); } function resolveOptions(input) { if (!input) return void 0; if (typeof input === "string") return { url: input, styleTag: true, inlineCss: true }; if (typeof input === "object" && "url" in input) return { url: input.url ?? "", tags: input.tags, attributes: input.attributes, styleTag: input.styleTag ?? true, inlineCss: input.inlineCss ?? true }; } function getTagConfig(tagName, options) { const { tags } = options; if (tags === void 0) return defaultTagConfig[tagName]; if (Array.isArray(tags)) { if (!tags.includes(tagName)) return void 0; return defaultTagConfig[tagName]; } if (typeof tags === "object") return tags[tagName]; } /** * Prepend a base URL to relative `src`/`href`/etc. references throughout * the document, including inside `<style>` blocks, inline `style` * attributes, MSO conditional comments, and VML tags. * * @param html HTML string to transform. * @param options Either a base URL string, or a {@link BaseUrlOptions} object * for finer control. * @returns The transformed HTML string. * * @example * import { base } from '@maizzle/framework' * * // Just a URL — applied with the built-in tag/attribute defaults. * const out = base('<img src="/a.png">', 'https://cdn.example.com/') * * // Restrict to specific tags, opt out of style rewriting: * const limited = base(html, { * url: 'https://cdn.example.com/', * tags: ['img'], * styleTag: false, * inlineCss: false, * }) */ function base(html, options) { return serialize(baseDom(parse(html), options)); } /** * DOM-form of {@link base} 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 baseDom(dom, options) { const resolved = resolveOptions(options); if (!resolved || !resolved.url) return dom; const { url: baseUrl, styleTag = true, inlineCss = true, attributes = {} } = resolved; walk(dom, (node) => { const el = node; if (!el.name) return; if (el.name === "style" && styleTag && el.children) { for (const child of el.children) if (child.type === "text") { const textNode = child; const processed = processCss(textNode.data, baseUrl); if (processed !== textNode.data) textNode.data = processed; } return; } const tagConfig = getTagConfig(el.name, resolved); if (tagConfig || resolved.tags === void 0) for (const [attr, value] of Object.entries(el.attribs)) { if (!value) continue; const attrConfig = tagConfig?.[attr]; if (!attrConfig && attr !== "style") continue; if (attr === "srcset" && (attrConfig === true || typeof attrConfig === "string")) { const newSrcset = processSrcset(value, typeof attrConfig === "string" ? attrConfig : baseUrl); if (newSrcset !== value) el.attribs.srcset = newSrcset; } else if (attr === "style" && inlineCss && value.includes("url(")) { const newStyle = processInlineStyle(value, baseUrl); if (newStyle !== value) el.attribs.style = newStyle; } else if (attrConfig === true && !isAbsoluteUrl(value)) el.attribs[attr] = baseUrl + value; else if (typeof attrConfig === "string" && !isAbsoluteUrl(value)) el.attribs[attr] = attrConfig + value; } for (const [attr, url] of Object.entries(attributes)) if (el.attribs[attr] && !isAbsoluteUrl(el.attribs[attr])) el.attribs[attr] = url + el.attribs[attr]; }); /** * VML and MSO comment rewrites require operating on serialized HTML * (HTML comments are not represented as traversable DOM nodes). */ const serialized = serialize(dom); const rewritten = rewriteMsoComments(rewriteVMLs(serialized, baseUrl), baseUrl); if (rewritten !== serialized) return parse(rewritten); return dom; } function rewriteVMLs(html, url) { html = html.replace(/<v:image[^>]+src="?([^"\s]+)"/gi, (match, src) => { if (isAbsoluteUrl(src)) return match; return match.replace(src, url + src); }); html = html.replace(/<v:fill[^>]+src="?([^"\s]+)"/gi, (match, src) => { if (isAbsoluteUrl(src)) return match; return match.replace(src, url + src); }); return html; } function rewriteMsoComments(html, url) { return html.replace(/<!--\[if [^\]]+\]>[\s\S]*?<!\[endif\]-->/g, (msoBlock) => { let result = msoBlock; for (const attr of sourceAttributes) { const attrRegex = new RegExp(`\\b${attr}="([^"]+)"`, "gi"); result = result.replace(attrRegex, (match, value) => { if (isAbsoluteUrl(value)) return match; if (attr === "srcset") return `srcset="${processSrcset(value, url)}"`; return `${attr}="${url}${value}"`; }); } result = result.replace(/style="([^"]+)"/gi, (match, style) => { if (!style.includes("url(")) return match; return `style="${processInlineStyle(style, url)}"`; }); return result; }); } //#endregion export { base, baseDom }; //# sourceMappingURL=base.js.map