@maizzle/framework
Version:
Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.
185 lines (184 loc) • 6.63 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 { 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