UNPKG

@maizzle/framework

Version:

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

527 lines (526 loc) 20.1 kB
import { walk } from "../utils/ast/walker.js"; import "../utils/ast/index.js"; import { horizontalBorderPx, horizontalPaddingPx, lengthToPx } from "../utils/cssBox.js"; import postcss from "postcss"; import safeParser from "postcss-safe-parser"; //#region src/transformers/columnWidth.ts const RE_PERCENT = /^[\d.]+%$/; /** * Stringify decls into a `; `-joined inline-style attribute. PostCSS raws * preserve the original source spacing, which mixes poorly with * the fresh decls we inject — plain join keeps output uniform. */ function serializeStyle(root) { const parts = []; root.walkDecls((d) => { parts.push(`${d.prop}: ${d.value}${d.important ? " !important" : ""}`); }); return parts.join("; "); } function firstDeclValue(root, prop) { let found; root.walkDecls(prop, (d) => { found = d.value; return false; }); return found; } /** * Find the user-set `min-width:` value on a column. Juice keeps both ours * and the one inlined from a class like `min-w-1/3` — we skip any * min-width whose value still contains our placeholder token, * returning the first remaining user value, or null. */ function findUserMinWidth(root) { let userVal = null; root.walkDecls("min-width", (d) => { if (!d.value.includes("__MAIZZLE_COLW_")) { userVal = d.value; return false; } }); return userVal; } function resolveLength(value) { const trimmed = value.trim(); if (RE_PERCENT.test(trimmed)) return trimmed; const m = trimmed.match(/^([\d.]+)(px|rem|em|pt)?$/i); if (!m) return null; const n = parseFloat(m[1]); const unit = (m[2] || "px").toLowerCase(); return `${Math.round(n * (unit === "rem" || unit === "em" ? 16 : unit === "pt" ? 1.333 : 1))}px`; } function divideLength(value, divisor) { const m = value.match(/^([\d.]+)(px|%)$/); if (!m || divisor < 1) return null; const n = parseFloat(m[1]); return `${parseFloat((n / divisor).toFixed(2))}${m[2]}`; } function subtractInsetPx(width, insetPx) { if (insetPx <= 0) return width; const m = width.match(/^([\d.]+)(px|%)$/); if (!m) return width; if (m[2] === "%") return width; const n = parseFloat(m[1]) - insetPx; return `${Math.max(0, Math.round(n))}px`; } /** * Return the smaller of two px lengths. Clamps our count-based min-width * down to the user's `max-width:` so the cap is never silently * violated when our computed min would exceed the user's max. */ function minPxLength(a, b) { const am = a.match(/^([\d.]+)px$/); const bm = b.match(/^([\d.]+)px$/); if (!am || !bm) return a; return parseFloat(am[1]) < parseFloat(bm[1]) ? a : b; } function depth(node) { let d = 0; let cur = node.parent; while (cur) { d++; cur = cur.parent ?? null; } return d; } function readWidthFromRoot(root) { const raw = firstDeclValue(root, "max-width") ?? firstDeclValue(root, "width") ?? firstDeclValue(root, "min-width"); return raw ? resolveLength(raw) : null; } function readHeightFromRoot(root) { const raw = firstDeclValue(root, "max-height") ?? firstDeclValue(root, "height") ?? firstDeclValue(root, "min-height"); return raw ? resolveLength(raw) : null; } function readWidthSource(el, root) { const explicit = el.attribs?.["data-maizzle-cw"]; if (explicit) { const r = resolveLength(explicit); if (r) return r; } return root ? readWidthFromRoot(root) : null; } /** * Convert a user-supplied length to absolute px against the column's source * width (post-inset). Percentages multiply against the source while * absolute units pass through `resolveLength`. Returns null when * the value or source can't be expressed in px. */ function userValueToPx(rawValue, sourcePx) { const trimmed = rawValue.trim(); if (trimmed.match(/^([\d.]+)(px|rem|em|pt)$/i)) return resolveLength(trimmed); const pctMatch = trimmed.match(/^([\d.]+)%$/); if (!pctMatch || !sourcePx) return null; const sourceMatch = sourcePx.match(/^([\d.]+)px$/); if (!sourceMatch) return null; const pct = parseFloat(pctMatch[1]); const src = parseFloat(sourceMatch[1]); return `${Math.round(pct / 100 * src)}px`; } /** * Resolve `__MAIZZLE_COLW_{id}__` and `__MAIZZLE_OH_{id}__` placeholders. * * COLW (column width) — emitted by `<Column>` and `<Overlap>`. Walks up to * the nearest ancestor marked `data-maizzle-cw` (Container, Section, * Row, or another Column already resolved) and divides the source * width by `data-maizzle-cw-count`. With `data-maizzle-cw-self`, * reads from the element's own inlined max/width/min-width * instead — used by `<Overlap>` with its own width class. * * OH (overlap height) — emitted by `<Overlap>`. Reads max-height, height, * or min-height from the element's own inlined style. * * Resolution rules: * - Style placeholders for `min-width`: replaced when resolvable, otherwise * the entire `min-width` declaration is stripped. * - Other style placeholders (Overlap td `width`, etc.): replaced when * resolvable, otherwise replaced with the count-based fallback or `100%`. * - Comment placeholders: same fallback chain. * * Resolved column widths are written back to `data-maizzle-cw` so nested * rows cascade. All `data-maizzle-cw*` and `data-maizzle-oh-*` are * stripped at the end of the second walk pass. */ function columnWidth(dom) { /** * Cache parsed style ASTs for this columnWidth invocation. The walk-up * loop visits the same Section/Container once per column of a Row, * so without caching each column re-parses every ancestor's style. * Cache is function-local — no cross-build leak via the WeakMap. */ const styleCache = /* @__PURE__ */ new WeakMap(); const parseElStyle = (el) => { const cached = styleCache.get(el); if (cached) return cached; const style = el.attribs?.style ?? ""; const root = style ? safeParser(style) : postcss.root(); styleCache.set(el, root); return root; }; const columns = []; const heightTargets = []; walk(dom, (node) => { const el = node; if (!el.attribs) return; const id = el.attribs["data-maizzle-cw-id"]; if (id) { const count = parseInt(el.attribs["data-maizzle-cw-count"] || "1", 10); const self = "data-maizzle-cw-self" in el.attribs; columns.push({ el, id, count, d: depth(node), self }); } const ohId = el.attribs["data-maizzle-oh-id"]; if (ohId) heightTargets.push({ el, id: ohId }); }); columns.sort((a, b) => a.d - b.d); const widthResolutions = /* @__PURE__ */ new Map(); const widthFallbacks = /* @__PURE__ */ new Map(); /** * Column ids whose absolute user `width:` was promoted to `min-width:` * — the original `width:` declaration must be stripped from the * column's style (otherwise it'd compete with the min-width). */ const stripWidth = /* @__PURE__ */ new Set(); /** * Column ids where the user wrote a percentage `width:` (e.g. `w-1/2`) — * explicit opt-out of px-based stacking. Keep the user's `width: X%` * and drop our `min-width:` placeholder so the column stays at * that percentage of its parent forever and never stacks. */ const dropMinWidth = /* @__PURE__ */ new Set(); /** * Column ids where the user wrote their own `min-width:` (via `min-w-1/3`). * Juice inlines theirs after ours, so two `min-width:` decls land in * the style — we strip the user's after using its value as the * column's resolution, leaving our placeholder as last word. */ const stripUserMinWidth = /* @__PURE__ */ new Set(); /** * Column ids where the user already supplied a `max-width:` of their own. * Our default `max-width: 100%` would just be shadowed by it via * last-wins and bloat the style — so we skip emitting it. */ const userHasMaxWidth = /* @__PURE__ */ new Set(); /** * Horizontal padding+border (px) of each column whose width was auto- * derived from count-based math. The column's own border/padding eats * its content box under content-box sizing, so the emitted `width:` * must be the slice MINUS this inset; otherwise two bordered cols * sum past the container and wrap. Only populated for auto paths * — user-explicit `width:`/`min-width:`/`max-width:`-wins paths * leave the value alone so the user's number stays the user's. */ const autoColumnInsets = /* @__PURE__ */ new Map(); /** * Extra inline-style decls that get stamped onto each column's MSO `<td>` * via its COLTDX placeholder. Carries `background-color` (so Word * paints the cell behind any padding area or whitespace, not just * the div) and hoisted `padding*` decls (when no border is present * — without one Word silently drops div padding, with one a td * copy would double-pad). The div keeps both for modern clients * since the MSO td is conditional-comment-only. */ const tdExtras = /* @__PURE__ */ new Map(); /** * MSO td width override for hoisted columns. With padding on the td and * CSS content-box sizing for table cells, td_outer = width + 2*pad, * so we set td width to the slot MINUS 2*horizontal-padding to keep * the cell at its outer slot. Skipped when the slot is a %. */ const hoistedTdWidths = /* @__PURE__ */ new Map(); for (const { id, count } of columns) widthFallbacks.set(id, `${Math.round(100 / Math.max(count, 1))}%`); const groupInfos = /* @__PURE__ */ new Map(); for (const c of columns) { if (c.self) continue; const ownRoot = parseElStyle(c.el); const ownInset = horizontalPaddingPx(ownRoot) + horizontalBorderPx(ownRoot); let cls = { kind: "auto", pxOuter: 0, pctOuter: 0 }; const userVal = findUserMinWidth(ownRoot) ?? firstDeclValue(ownRoot, "width"); if (userVal) { const resolved = resolveLength(userVal); if (resolved?.endsWith("px")) { const px = lengthToPx(resolved); if (px != null) cls = { kind: "explicit", pxOuter: px + ownInset, pctOuter: 0 }; } else if (resolved?.endsWith("%")) cls = { kind: "explicit", pxOuter: ownInset, pctOuter: parseFloat(resolved) }; } const parent = c.el.parent; if (!parent) continue; let info = groupInfos.get(parent); if (!info) { info = { explicitPxOuterSum: 0, explicitPctOuterSum: 0, autoCount: 0 }; groupInfos.set(parent, info); } info.explicitPxOuterSum += cls.pxOuter; info.explicitPctOuterSum += cls.pctOuter; if (cls.kind === "auto") info.autoCount++; } for (const { el, id, count, self } of columns) { const ownRoot = parseElStyle(el); let sourceWidth = null; let accumulatedInsetPx = 0; if (self) { sourceWidth = readWidthFromRoot(ownRoot); accumulatedInsetPx = horizontalPaddingPx(ownRoot) + horizontalBorderPx(ownRoot); } else { /** * Walk up through every ancestor with attribs, accumulating horizontal * padding+border along the way (including the source). Stop at the * first `data-maizzle-cw` ancestor whose width is resolvable. * Markers without a resolvable width (Row emitted empty after * Tailwind dropped a bogus class) shouldn't shadow a real * width on a higher ancestor like `<Container>`. * * With CSS content-box this is technically generous toward the * source's own padding/border, but matches user expectations * when they put `px-9` or `border-2` on a wrapper. */ let cur = el.parent; while (cur) { const parentEl = cur; if (parentEl.attribs) { let pRoot = null; if (parentEl.attribs.style) { pRoot = parseElStyle(parentEl); accumulatedInsetPx += horizontalPaddingPx(pRoot) + horizontalBorderPx(pRoot); } if ("data-maizzle-cw" in parentEl.attribs) { const w = readWidthSource(parentEl, pRoot); if (w) { sourceWidth = w; break; } } } cur = cur.parent ?? null; } } const adjusted = sourceWidth ? subtractInsetPx(sourceWidth, accumulatedInsetPx) : null; let countBased = adjusted ? divideLength(adjusted, count) : null; /** * Sibling-aware redistribution kicks in only when the auto col has at * least one explicit-width sibling and the source is in px (can't * mix px subtraction with a % source). When that's true, the auto * col's share becomes `(source − sum_explicit_px − pct% of source) * / autoCount`, replacing the naive `source/totalCount`. */ if (!self && adjusted?.endsWith("px") && countBased && el.parent) { const group = groupInfos.get(el.parent); if (group && group.autoCount > 0 && (group.explicitPxOuterSum > 0 || group.explicitPctOuterSum > 0)) { const adjPx = lengthToPx(adjusted); if (adjPx != null) { const remaining = adjPx - group.explicitPxOuterSum - adjPx * group.explicitPctOuterSum / 100; countBased = `${Math.max(0, Math.floor(remaining * 100 / group.autoCount) / 100)}px`; } } } /** * Four user-override paths, decided by which CSS property the user * actually wrote: * * - `min-width: X` → user's value wins. Convert to px against * the source (if %), use as the column's * resolution, and strip the user's min-width * declaration so our placeholder substitution * remains the last `min-width:` in style. * - `width: X%` → opt-out of px stacking. Keep `width:` in * style, drop our `min-width:` placeholder. * Cols stay at X% of parent forever, never stack. * - `width: Xpx` (or rem/em/pt) → fixed pixel column. Promote to * `min-width:`, strip the original `width:` so * it doesn't compete. * - `max-width: X` → CSS cap. Keep the `max-width:` declaration; * clamp our count-based min-width *down* to * the user's max-width when our min would * otherwise violate it. */ const userMinRaw = findUserMinWidth(ownRoot); const widthRaw = firstDeclValue(ownRoot, "width"); const maxRaw = firstDeclValue(ownRoot, "max-width"); if (userMinRaw) { const minPx = userValueToPx(userMinRaw, adjusted) ?? resolveLength(userMinRaw); if (minPx) { widthResolutions.set(id, minPx); el.attribs["data-maizzle-cw"] = minPx; stripUserMinWidth.add(id); continue; } } if (widthRaw) { const widthVal = resolveLength(widthRaw); if (widthVal?.endsWith("%")) { widthResolutions.set(id, widthVal); el.attribs["data-maizzle-cw"] = widthVal; dropMinWidth.add(id); continue; } if (widthVal) { widthResolutions.set(id, widthVal); el.attribs["data-maizzle-cw"] = widthVal; stripWidth.add(id); continue; } } if (maxRaw && countBased) { const maxPx = userValueToPx(maxRaw, adjusted); if (maxPx) { const cappedMin = countBased.endsWith("px") ? minPxLength(countBased, maxPx) : maxPx; widthResolutions.set(id, cappedMin); el.attribs["data-maizzle-cw"] = cappedMin; userHasMaxWidth.add(id); if (cappedMin === countBased) { const ownInset = horizontalPaddingPx(ownRoot) + horizontalBorderPx(ownRoot); if (ownInset > 0) autoColumnInsets.set(id, ownInset); } continue; } } if (countBased) { widthResolutions.set(id, countBased); el.attribs["data-maizzle-cw"] = countBased; const ownPaddingPx = horizontalPaddingPx(ownRoot); const ownBorderPx = horizontalBorderPx(ownRoot); const ownInset = ownPaddingPx + ownBorderPx; if (ownInset > 0) autoColumnInsets.set(id, ownInset); /** * Build the MSO td's "extras" string — decls that need to live on * the td in addition to width + vertical-align. Two contributors: * * - `background-color` (always, when present) — Word renders the * div bg inside the cell, but anything outside the div (the * td's padding area when hoisted, or any whitespace gap) * would show the parent's bg instead of the column's. Painting * the td matches the user's intent. * * - `padding*` (hoisted only when no horizontal border) — Word * drops div padding without a stabilizing border, so the td * has to carry it. With a border, Word renders div padding * and a td copy would double-pad. Skip when the slot is `%`: * td width math can't subtract px padding from a percentage. */ const extras = []; let bgColor; ownRoot.walkDecls("background-color", (d) => { bgColor = d.value; }); if (bgColor) extras.push(`background-color: ${bgColor}`); if (ownPaddingPx > 0 && ownBorderPx === 0 && countBased.endsWith("px")) { ownRoot.walkDecls((d) => { if (/^padding(-|$)/.test(d.prop)) extras.push(`${d.prop}: ${d.value}`); }); hoistedTdWidths.set(id, subtractInsetPx(countBased, ownPaddingPx)); } if (extras.length) tdExtras.set(id, extras.join("; ")); } } const heightResolutions = /* @__PURE__ */ new Map(); for (const { el, id } of heightTargets) { if (!el.attribs?.style) continue; const h = readHeightFromRoot(parseElStyle(el)); if (h) heightResolutions.set(id, h); } walk(dom, (node) => { if (node.type === "comment") { const data = node.data; if (!data) return; const hasCW = data.includes("__MAIZZLE_COLW_"); const hasOH = data.includes("__MAIZZLE_OH_"); const hasTDX = data.includes("__MAIZZLE_COLTDX_"); if (!hasCW && !hasOH && !hasTDX) return; node.data = data.replace(/__MAIZZLE_COLW_([^_]+)__/g, (_m, mid) => hoistedTdWidths.get(mid) ?? widthResolutions.get(mid) ?? widthFallbacks.get(mid) ?? "100%").replace(/;\s*__MAIZZLE_COLTDX_([^_]+)__/g, (_m, mid) => { const pad = tdExtras.get(mid); return pad ? `; ${pad}` : ""; }).replace(/__MAIZZLE_OH_([^_]+)__/g, (_m, hid) => heightResolutions.get(hid) ?? "100%"); return; } const el = node; if (!el.attribs) return; const style = el.attribs.style; if (style && (style.includes("__MAIZZLE_COLW_") || style.includes("__MAIZZLE_OH_"))) { const root = parseElStyle(el); const cwId = el.attribs["data-maizzle-cw-id"]; /** * Strip user dups BEFORE substitution — last-wins CSS would * otherwise shadow our resolved values in the output. */ if (cwId && stripUserMinWidth.has(cwId)) root.walkDecls("min-width", (d) => { if (!d.value.includes("__MAIZZLE_COLW_")) d.remove(); }); if (cwId && stripWidth.has(cwId)) root.walkDecls("width", (d) => { d.remove(); }); /** * Substitute the column's `min-width:` placeholder with `width: <res>; * max-width: 100%`. Width gives the same stacking trigger as * min-width — inline-block wraps when children sum > parent * — and the `max-width: 100%` clamp keeps the column from * overflowing the viewport once it drops to its own row on * mobile. Skip the clamp when the user supplied their own. * * Other placeholders (Overlap td `width`, comment markers, * OH height) get a plain value substitution. */ root.walkDecls((d) => { if (d.prop === "min-width") { const m = d.value.match(/^__MAIZZLE_COLW_([^_]+)__$/); if (m) { const mid = m[1]; if (dropMinWidth.has(mid) || !widthResolutions.has(mid)) { d.remove(); return; } let resolved = widthResolutions.get(mid); const inset = autoColumnInsets.get(mid); if (inset) resolved = subtractInsetPx(resolved, inset); const repl = [postcss.decl({ prop: "width", value: resolved })]; if (!userHasMaxWidth.has(mid)) repl.push(postcss.decl({ prop: "max-width", value: "100%" })); d.replaceWith(...repl); return; } } if (d.value.includes("__MAIZZLE_COLW_") || d.value.includes("__MAIZZLE_OH_")) d.value = d.value.replace(/__MAIZZLE_COLW_([^_]+)__/g, (_m, mid) => widthResolutions.get(mid) ?? widthFallbacks.get(mid) ?? "100%").replace(/__MAIZZLE_OH_([^_]+)__/g, (_m, hid) => heightResolutions.get(hid) ?? "100%"); }); const out = serializeStyle(root); if (out) el.attribs.style = out; else delete el.attribs.style; } delete el.attribs["data-maizzle-cw"]; delete el.attribs["data-maizzle-cw-id"]; delete el.attribs["data-maizzle-cw-count"]; delete el.attribs["data-maizzle-cw-self"]; delete el.attribs["data-maizzle-oh-id"]; }); return dom; } //#endregion export { columnWidth }; //# sourceMappingURL=columnWidth.js.map