🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@templatical/renderer

Package Overview
Dependencies
Maintainers
1
Versions
43
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@templatical/renderer - npm Package Compare versions

Comparing version
0.10.0
to
0.10.1
+108
-98
dist/index.d.ts

@@ -1,3 +0,4 @@

import { CustomFont, Block, ColumnLayout, SpacingValue, CustomBlock, TemplateContent } from '@templatical/types';
import { Block, ColumnLayout, CustomBlock, CustomFont, SpacingValue, TemplateContent } from "@templatical/types";
//#region src/render-context.d.ts
declare const DEFAULT_SOCIAL_ICONS_BASE_URL: string;

@@ -8,48 +9,50 @@ /**

declare class RenderContext {
readonly containerWidth: number;
readonly customFonts: CustomFont[];
readonly defaultFallbackFont: string;
readonly allowHtmlBlocks: boolean;
/**
* Map of custom block id → pre-rendered HTML, populated by `renderToMjml`
* before the synchronous render pass. Set when the consumer provides a
* `renderCustomBlock` option. Empty by default.
*/
readonly customBlockHtml: ReadonlyMap<string, string>;
/**
* Base URL (no trailing slash) for the social icon PNG assets. Resolved to
* `${baseUrl}/${style}/${platform}.png`. Outlook desktop has no SVG support
* and rejects base64 data URIs in `<img src>`, so PNGs must be served over
* HTTP. Default points at the version-pinned unpkg mirror of this
* package; consumers can override to self-host.
*/
readonly socialIconsBaseUrl: string;
constructor(containerWidth: number, customFonts: CustomFont[], defaultFallbackFont: string, allowHtmlBlocks: boolean,
/**
* Map of custom block id → pre-rendered HTML, populated by `renderToMjml`
* before the synchronous render pass. Set when the consumer provides a
* `renderCustomBlock` option. Empty by default.
*/
customBlockHtml?: ReadonlyMap<string, string>,
/**
* Base URL (no trailing slash) for the social icon PNG assets. Resolved to
* `${baseUrl}/${style}/${platform}.png`. Outlook desktop has no SVG support
* and rejects base64 data URIs in `<img src>`, so PNGs must be served over
* HTTP. Default points at the version-pinned unpkg mirror of this
* package; consumers can override to self-host.
*/
socialIconsBaseUrl?: string);
/**
* Create a new context with a different container width.
* Used when rendering columns with narrower widths.
*/
withContainerWidth(width: number): RenderContext;
/**
* Resolve a font family name to include custom font fallbacks.
* If the font matches a custom font, returns `'FontName', fallback`.
* Otherwise returns the original font family string.
*/
resolveFontFamily(fontFamily: string): string;
readonly containerWidth: number;
readonly customFonts: CustomFont[];
readonly defaultFallbackFont: string;
readonly allowHtmlBlocks: boolean;
/**
* Map of custom block id → pre-rendered HTML, populated by `renderToMjml`
* before the synchronous render pass. Set when the consumer provides a
* `renderCustomBlock` option. Empty by default.
*/
readonly customBlockHtml: ReadonlyMap<string, string>;
/**
* Base URL (no trailing slash) for the social icon PNG assets. Resolved to
* `${baseUrl}/${style}/${platform}.png`. Outlook desktop has no SVG support
* and rejects base64 data URIs in `<img src>`, so PNGs must be served over
* HTTP. Default points at the version-pinned unpkg mirror of this
* package; consumers can override to self-host.
*/
readonly socialIconsBaseUrl: string;
constructor(containerWidth: number, customFonts: CustomFont[], defaultFallbackFont: string, allowHtmlBlocks: boolean,
/**
* Map of custom block id → pre-rendered HTML, populated by `renderToMjml`
* before the synchronous render pass. Set when the consumer provides a
* `renderCustomBlock` option. Empty by default.
*/
customBlockHtml?: ReadonlyMap<string, string>,
/**
* Base URL (no trailing slash) for the social icon PNG assets. Resolved to
* `${baseUrl}/${style}/${platform}.png`. Outlook desktop has no SVG support
* and rejects base64 data URIs in `<img src>`, so PNGs must be served over
* HTTP. Default points at the version-pinned unpkg mirror of this
* package; consumers can override to self-host.
*/
socialIconsBaseUrl?: string);
/**
* Create a new context with a different container width.
* Used when rendering columns with narrower widths.
*/
withContainerWidth(width: number): RenderContext;
/**
* Resolve a font family name to include custom font fallbacks.
* If the font matches a custom font, returns `'FontName', fallback`.
* Otherwise returns the original font family string.
*/
resolveFontFamily(fontFamily: string): string;
}
//#endregion
//#region src/escape.d.ts
/**

@@ -77,3 +80,4 @@ * Escape HTML special characters (& < > " ').

declare function convertMergeTagsToValues(html: string): string;
//#endregion
//#region src/visibility.d.ts
/**

@@ -92,3 +96,4 @@ * Check if a block is hidden on all viewports.

declare function getCssClasses(block: Block): string;
//#endregion
//#region src/columns.d.ts
/**

@@ -102,3 +107,4 @@ * Get width percentages for each column in a layout.

declare function getWidthPixels(layout: ColumnLayout, containerWidth: number): number[];
//#endregion
//#region src/padding.d.ts
/**

@@ -108,3 +114,4 @@ * Convert a SpacingValue to a CSS padding string like "10px 10px 10px 10px".

declare function toPaddingString(padding: SpacingValue): string;
//#endregion
//#region src/renderers/index.d.ts
/**

@@ -115,3 +122,4 @@ * Render a single block to MJML markup.

declare function renderBlock(block: Block, context: RenderContext): string;
//#endregion
//#region src/renderers/section.d.ts
/**

@@ -121,48 +129,49 @@ * A function type that renders a single block to MJML markup.

type BlockRenderer = (block: Block, context: RenderContext) => string;
//#endregion
//#region src/index.d.ts
interface RenderOptions {
customFonts?: CustomFont[];
defaultFallbackFont?: string;
allowHtmlBlocks?: boolean;
/**
* Resolves custom blocks to their HTML representation. Called once per
* custom block in the content tree before MJML rendering. The renderer
* has no built-in knowledge of how to render custom blocks; consumers
* provide this function.
*
* Editor consumers: pass `editor.renderCustomBlock`.
*
* Headless consumers (Node.js, server, CLI): provide your own resolver,
* typically using the same liquid template + field values pipeline as
* the editor uses. If omitted, custom blocks fall back to
* `block.renderedHtml` (if present) and otherwise are omitted from the
* output.
*/
renderCustomBlock?: (block: CustomBlock) => Promise<string>;
/**
* Resolves the definition-level CSS for a custom block type. Called once
* per unique `customType` present in the content tree (not per instance).
* The non-empty results are deduped by content and emitted as additional
* `<mj-style>` blocks inside `<mj-head>` alongside the built-in visibility
* media queries.
*
* Editor consumers: pass a function that reads
* `blockRegistry.getDefinition(customType)?.stylesheet`.
*
* Headless consumers: provide your own resolver, typically from the same
* definitions map used by `renderCustomBlock`. Return `undefined` or `null`
* for definitions without a stylesheet — those are skipped.
*/
getCustomBlockStylesheet?: (customType: string) => string | undefined | null;
/**
* Base URL (no trailing slash) for the social icon PNG assets. Resolved to
* `${baseUrl}/${style}/${platform}.png` per icon. Defaults to the
* version-pinned unpkg mirror of this package. Override to self-host
* (e.g., behind your own CDN or for air-gapped environments).
*
* Why PNGs: Outlook desktop (Word rendering engine) does not support SVG
* and rejects base64 data URIs in `<img src>`, so social icons must be
* served as raster images over HTTP for cross-client compatibility.
*/
socialIconsBaseUrl?: string;
customFonts?: CustomFont[];
defaultFallbackFont?: string;
allowHtmlBlocks?: boolean;
/**
* Resolves custom blocks to their HTML representation. Called once per
* custom block in the content tree before MJML rendering. The renderer
* has no built-in knowledge of how to render custom blocks; consumers
* provide this function.
*
* Editor consumers: pass `editor.renderCustomBlock`.
*
* Headless consumers (Node.js, server, CLI): provide your own resolver,
* typically using the same liquid template + field values pipeline as
* the editor uses. If omitted, custom blocks fall back to
* `block.renderedHtml` (if present) and otherwise are omitted from the
* output.
*/
renderCustomBlock?: (block: CustomBlock) => Promise<string>;
/**
* Resolves the definition-level CSS for a custom block type. Called once
* per unique `customType` present in the content tree (not per instance).
* The non-empty results are deduped by content and emitted as additional
* `<mj-style>` blocks inside `<mj-head>` alongside the built-in visibility
* media queries.
*
* Editor consumers: pass a function that reads
* `blockRegistry.getDefinition(customType)?.stylesheet`.
*
* Headless consumers: provide your own resolver, typically from the same
* definitions map used by `renderCustomBlock`. Return `undefined` or `null`
* for definitions without a stylesheet — those are skipped.
*/
getCustomBlockStylesheet?: (customType: string) => string | undefined | null;
/**
* Base URL (no trailing slash) for the social icon PNG assets. Resolved to
* `${baseUrl}/${style}/${platform}.png` per icon. Defaults to the
* version-pinned unpkg mirror of this package. Override to self-host
* (e.g., behind your own CDN or for air-gapped environments).
*
* Why PNGs: Outlook desktop (Word rendering engine) does not support SVG
* and rejects base64 data URIs in `<img src>`, so social icons must be
* served as raster images over HTTP for cross-client compatibility.
*/
socialIconsBaseUrl?: string;
}

@@ -179,3 +188,4 @@ /**

declare function renderToMjml(content: TemplateContent, options?: RenderOptions): Promise<string>;
export { type BlockRenderer, DEFAULT_SOCIAL_ICONS_BASE_URL, RenderContext, type RenderOptions, convertMergeTagsToValues, escapeAttr, escapeHtml, getCssClassAttr, getCssClasses, getWidthPercentages, getWidthPixels, isHiddenOnAll, renderBlock, renderToMjml, toPaddingString };
//#endregion
export { type BlockRenderer, DEFAULT_SOCIAL_ICONS_BASE_URL, RenderContext, RenderOptions, convertMergeTagsToValues, escapeAttr, escapeHtml, getCssClassAttr, getCssClasses, getWidthPercentages, getWidthPixels, isHiddenOnAll, renderBlock, renderToMjml, toPaddingString };
//# sourceMappingURL=index.d.ts.map

@@ -1,279 +0,232 @@

// src/index.ts
import { isSection as isSection3, isCustomBlock as isCustomBlock2 } from "@templatical/types";
// package.json
var package_default = {
name: "@templatical/renderer",
description: "Render Templatical email templates to MJML",
version: "0.10.0",
bugs: "https://github.com/templatical/sdk/issues",
dependencies: {
"@templatical/types": "workspace:*"
},
devDependencies: {
"@resvg/resvg-js": "^2.6.2",
mjml: "^5.2.2",
tsup: "^8.5.1",
typescript: "^6.0.3",
vitest: "^4.1.7"
},
exports: {
".": {
types: "./dist/index.d.ts",
import: "./dist/index.js"
}
},
files: [
"dist",
"assets"
],
homepage: "https://templatical.com",
keywords: [
"email",
"email-template",
"html-email",
"mjml",
"renderer",
"templatical"
],
license: "MIT",
module: "./dist/index.js",
publishConfig: {
access: "public"
},
repository: {
type: "git",
url: "git+https://github.com/templatical/sdk.git",
directory: "packages/renderer"
},
scripts: {
build: "tsup && node scripts/rasterize-social.mjs",
test: "vitest run --config vitest.config.ts",
typecheck: "tsc --noEmit"
},
type: "module",
types: "./dist/index.d.ts"
import { HEADING_LEVEL_FONT_SIZE, isButton, isCustomBlock, isDivider, isHtml, isImage, isMenu, isParagraph, isSection, isSocialIcons, isSpacer, isTable, isTitle, isVideo } from "@templatical/types";
//#endregion
//#region src/render-context.ts
const DEFAULT_SOCIAL_ICONS_BASE_URL = `https://unpkg.com/@templatical/renderer@0.10.1/assets/social`;
const BUILT_IN_FONT_FALLBACKS = {
arial: "Arial, sans-serif",
helvetica: "Helvetica, sans-serif",
georgia: "Georgia, serif",
"times new roman": "'Times New Roman', serif",
verdana: "Verdana, sans-serif",
"trebuchet ms": "'Trebuchet MS', sans-serif",
"courier new": "'Courier New', monospace",
tahoma: "Tahoma, sans-serif"
};
// src/render-context.ts
var DEFAULT_SOCIAL_ICONS_BASE_URL = `https://unpkg.com/@templatical/renderer@${package_default.version}/assets/social`;
var BUILT_IN_FONT_FALLBACKS = {
arial: "Arial, sans-serif",
helvetica: "Helvetica, sans-serif",
georgia: "Georgia, serif",
"times new roman": "'Times New Roman', serif",
verdana: "Verdana, sans-serif",
"trebuchet ms": "'Trebuchet MS', sans-serif",
"courier new": "'Courier New', monospace",
tahoma: "Tahoma, sans-serif"
/**
* Immutable context passed through the block rendering chain.
*/
var RenderContext = class RenderContext {
containerWidth;
customFonts;
defaultFallbackFont;
allowHtmlBlocks;
customBlockHtml;
socialIconsBaseUrl;
constructor(containerWidth, customFonts, defaultFallbackFont, allowHtmlBlocks, customBlockHtml = /* @__PURE__ */ new Map(), socialIconsBaseUrl = DEFAULT_SOCIAL_ICONS_BASE_URL) {
this.containerWidth = containerWidth;
this.customFonts = customFonts;
this.defaultFallbackFont = defaultFallbackFont;
this.allowHtmlBlocks = allowHtmlBlocks;
this.customBlockHtml = customBlockHtml;
this.socialIconsBaseUrl = socialIconsBaseUrl;
}
/**
* Create a new context with a different container width.
* Used when rendering columns with narrower widths.
*/
withContainerWidth(width) {
return new RenderContext(width, this.customFonts, this.defaultFallbackFont, this.allowHtmlBlocks, this.customBlockHtml, this.socialIconsBaseUrl);
}
/**
* Resolve a font family name to include custom font fallbacks.
* If the font matches a custom font, returns `'FontName', fallback`.
* Otherwise returns the original font family string.
*/
resolveFontFamily(fontFamily) {
for (const customFont of this.customFonts) if (customFont.name.toLowerCase() === fontFamily.toLowerCase()) {
const fallback = customFont.fallback ?? this.defaultFallbackFont;
return `'${customFont.name}', ${fallback}`;
}
const builtIn = BUILT_IN_FONT_FALLBACKS[fontFamily.toLowerCase()];
if (builtIn) return builtIn;
return fontFamily;
}
};
var RenderContext = class _RenderContext {
constructor(containerWidth, customFonts, defaultFallbackFont, allowHtmlBlocks, customBlockHtml = /* @__PURE__ */ new Map(), socialIconsBaseUrl = DEFAULT_SOCIAL_ICONS_BASE_URL) {
this.containerWidth = containerWidth;
this.customFonts = customFonts;
this.defaultFallbackFont = defaultFallbackFont;
this.allowHtmlBlocks = allowHtmlBlocks;
this.customBlockHtml = customBlockHtml;
this.socialIconsBaseUrl = socialIconsBaseUrl;
}
containerWidth;
customFonts;
defaultFallbackFont;
allowHtmlBlocks;
customBlockHtml;
socialIconsBaseUrl;
/**
* Create a new context with a different container width.
* Used when rendering columns with narrower widths.
*/
withContainerWidth(width) {
return new _RenderContext(
width,
this.customFonts,
this.defaultFallbackFont,
this.allowHtmlBlocks,
this.customBlockHtml,
this.socialIconsBaseUrl
);
}
/**
* Resolve a font family name to include custom font fallbacks.
* If the font matches a custom font, returns `'FontName', fallback`.
* Otherwise returns the original font family string.
*/
resolveFontFamily(fontFamily) {
for (const customFont of this.customFonts) {
if (customFont.name.toLowerCase() === fontFamily.toLowerCase()) {
const fallback = customFont.fallback ?? this.defaultFallbackFont;
return `'${customFont.name}', ${fallback}`;
}
}
const builtIn = BUILT_IN_FONT_FALLBACKS[fontFamily.toLowerCase()];
if (builtIn) {
return builtIn;
}
return fontFamily;
}
//#endregion
//#region src/escape.ts
const HTML_ENTITIES = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#039;"
};
// src/renderers/index.ts
import {
isSection as isSection2,
isTitle,
isParagraph,
isImage,
isButton,
isDivider,
isSpacer,
isHtml,
isSocialIcons,
isMenu,
isTable,
isVideo,
isCustomBlock
} from "@templatical/types";
// src/renderers/title.ts
import { HEADING_LEVEL_FONT_SIZE } from "@templatical/types";
// src/escape.ts
var HTML_ENTITIES = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
};
var HTML_ENTITY_REGEX = /[&<>"']/g;
const HTML_ENTITY_REGEX = /[&<>"']/g;
/**
* Escape HTML special characters (& < > " ').
* Equivalent to PHP htmlspecialchars with ENT_QUOTES | ENT_HTML5.
*/
function escapeHtml(text) {
if (text === "") {
return "";
}
return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
if (text === "") return "";
return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
}
/**
* Escape a string for use in an HTML attribute value.
* Same implementation as escapeHtml for consistency with PHP.
*/
function escapeAttr(text) {
if (text === "") {
return "";
}
return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
if (text === "") return "";
return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
}
/**
* Escape a string for use as a CSS property value inside an inline
* `style="prop: ${value}"` attribute. Beyond HTML entity escaping (so the
* value survives the attribute boundary), this strips characters that
* could break out of the property value into a sibling property:
*
* `;` — separates CSS declarations
* `{`/`}` — opens/closes a CSS rule (rejected by attribute parsers but
* still safer to remove)
* `\n`/`\r` — would smuggle past line-based CSS sanitizers
*
* Without this, an attacker-controlled color like
* `"red; background: url('//attacker/log')"` lands as a real CSS rule.
*/
function escapeCssValue(text) {
if (text === "") {
return "";
}
return escapeAttr(text).replace(/[;{}\r\n]/g, "");
if (text === "") return "";
return escapeAttr(text).replace(/[;{}\r\n]/g, "");
}
/**
* Replace merge tag span elements with their data attribute values.
* Converts `<span data-merge-tag="{{name}}">Label</span>` to `{{name}}`.
* Also handles `data-logic-merge-tag` attributes.
*
* Uses a single-pass linear scan instead of an `[^>]*…[^>]*` regex because
* the latter is polynomial-ReDoS over inputs that contain many `<span`
* starts but no closing `>` — the engine retries `[^>]*` at every span
* position. The scan below resolves each `<span>` open tag with a bounded
* `indexOf('>')`, keeping the work strictly O(n).
*/
function convertMergeTagsToValues(html) {
if (html === "") {
return "";
}
return rewriteMergeTagSpans(
html,
(attrs) => findAttr(attrs, "data-merge-tag") ?? findAttr(attrs, "data-logic-merge-tag")
);
if (html === "") return "";
return rewriteMergeTagSpans(html, (attrs) => findAttr(attrs, "data-merge-tag") ?? findAttr(attrs, "data-logic-merge-tag"));
}
/**
* Walk `html`, find every `<span …>…</span>`, and replace the entire span
* with whatever `extract` returns for its attribute string (or leave it
* alone if `extract` returns `null`). Linear in the length of `html`:
* every `indexOf` advances the cursor monotonically.
*/
function rewriteMergeTagSpans(html, extract) {
let out = "";
let i = 0;
while (i < html.length) {
const open = html.indexOf("<span", i);
if (open === -1) {
out += html.substring(i);
break;
}
const afterTagName = html[open + 5];
if (afterTagName !== ">" && afterTagName !== " " && afterTagName !== " " && afterTagName !== "\n" && afterTagName !== "\r" && afterTagName !== "/") {
out += html.substring(i, open + 5);
i = open + 5;
continue;
}
const openEnd = html.indexOf(">", open + 5);
if (openEnd === -1) {
out += html.substring(i);
break;
}
const closeStart = html.indexOf("</span>", openEnd + 1);
if (closeStart === -1) {
out += html.substring(i);
break;
}
const attrs = html.substring(open + 5, openEnd);
const replacement = extract(attrs);
if (replacement === null) {
out += html.substring(i, open + 5);
i = open + 5;
continue;
}
out += html.substring(i, open);
out += replacement;
i = closeStart + 7;
}
return out;
let out = "";
let i = 0;
while (i < html.length) {
const open = html.indexOf("<span", i);
if (open === -1) {
out += html.substring(i);
break;
}
const afterTagName = html[open + 5];
if (afterTagName !== ">" && afterTagName !== " " && afterTagName !== " " && afterTagName !== "\n" && afterTagName !== "\r" && afterTagName !== "/") {
out += html.substring(i, open + 5);
i = open + 5;
continue;
}
const openEnd = html.indexOf(">", open + 5);
if (openEnd === -1) {
out += html.substring(i);
break;
}
const closeStart = html.indexOf("</span>", openEnd + 1);
if (closeStart === -1) {
out += html.substring(i);
break;
}
const replacement = extract(html.substring(open + 5, openEnd));
if (replacement === null) {
out += html.substring(i, open + 5);
i = open + 5;
continue;
}
out += html.substring(i, open);
out += replacement;
i = closeStart + 7;
}
return out;
}
/**
* Extract the value of `name="…"` from an HTML attribute string, or `null`
* if absent. Uses `[^<>"]*` for the value match so a missing closing quote
* fails fast rather than backtracking across the full input.
*/
function findAttr(attrs, name) {
const pattern = new RegExp(`(?:^|\\s)${name}="([^"<>]*)"`);
const match = pattern.exec(attrs);
return match ? match[1] : null;
const match = new RegExp(`(?:^|\\s)${name}="([^"<>]*)"`).exec(attrs);
return match ? match[1] : null;
}
// src/padding.ts
//#endregion
//#region src/padding.ts
/**
* Convert a SpacingValue to a CSS padding string like "10px 10px 10px 10px".
*/
function toPaddingString(padding) {
return `${padding.top}px ${padding.right}px ${padding.bottom}px ${padding.left}px`;
return `${padding.top}px ${padding.right}px ${padding.bottom}px ${padding.left}px`;
}
// src/utils.ts
//#endregion
//#region src/utils.ts
/**
* Render the appropriate background-color attribute for an MJML element.
* Returns an empty string when no color is set, or a leading-space attribute
* fragment ready to interpolate into a tag's attribute list.
*/
function bgAttr(backgroundColor, placement) {
if (!backgroundColor) {
return "";
}
const name = placement === "native" ? "background-color" : "container-background-color";
return ` ${name}="${backgroundColor}"`;
if (!backgroundColor) return "";
return ` ${placement === "native" ? "background-color" : "container-background-color"}="${backgroundColor}"`;
}
// src/visibility.ts
//#endregion
//#region src/visibility.ts
/**
* Check if a block is hidden on all viewports.
*/
function isHiddenOnAll(block) {
const visibility = block.visibility;
if (!visibility) {
return false;
}
return !visibility.desktop && !visibility.mobile;
const visibility = block.visibility;
if (!visibility) return false;
return !visibility.desktop && !visibility.mobile;
}
/**
* Get the MJML css-class attribute string for visibility hiding.
* Returns a string like ` css-class="tpl-hide-desktop"` or empty string.
*/
function getCssClassAttr(block) {
const classes = getCssClasses(block);
if (classes === "") {
return "";
}
return ` css-class="${classes}"`;
const classes = getCssClasses(block);
if (classes === "") return "";
return ` css-class="${classes}"`;
}
/**
* Get the CSS classes for visibility hiding.
*/
function getCssClasses(block) {
const visibility = block.visibility;
if (!visibility) {
return "";
}
const classes = [];
if (!visibility.desktop) {
classes.push("tpl-hide-desktop");
}
if (!visibility.mobile) {
classes.push("tpl-hide-mobile");
}
return classes.join(" ");
const visibility = block.visibility;
if (!visibility) return "";
const classes = [];
if (!visibility.desktop) classes.push("tpl-hide-desktop");
if (!visibility.mobile) classes.push("tpl-hide-mobile");
return classes.join(" ");
}
// src/renderers/title.ts
//#endregion
//#region src/renderers/title.ts
/**
* Render a title block to MJML markup.
*/
function renderTitle(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const content = unwrapParagraph(convertMergeTagsToValues(block.content));
const fontSize = HEADING_LEVEL_FONT_SIZE[block.level] ?? HEADING_LEVEL_FONT_SIZE[2];
const color = escapeAttr(block.color);
const align = block.textAlign;
const fontFamilyAttr = renderFontFamilyAttr(block.fontFamily, context);
const visibilityAttr = getCssClassAttr(block);
const safeLevel = HEADING_LEVEL_FONT_SIZE[block.level] ? block.level : 2;
const tag = `h${safeLevel}`;
return `<mj-text
if (isHiddenOnAll(block)) return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const content = unwrapParagraph(convertMergeTagsToValues(block.content));
const fontSize = HEADING_LEVEL_FONT_SIZE[block.level] ?? HEADING_LEVEL_FONT_SIZE[2];
const color = escapeAttr(block.color);
const align = block.textAlign;
const fontFamilyAttr = renderFontFamilyAttr$3(block.fontFamily, context);
const visibilityAttr = getCssClassAttr(block);
const tag = `h${HEADING_LEVEL_FONT_SIZE[block.level] ? block.level : 2}`;
return `<mj-text
font-size="${fontSize}px"

@@ -286,87 +239,79 @@ color="${color}"

}
/**
* The editor stores title content as a TipTap paragraph (`<p>...</p>`),
* but the renderer wraps it in `<h${level}>`. `<p>` is invalid inside a
* heading, so strip a single outer `<p>` wrapper if present.
*/
function unwrapParagraph(html) {
const match = html.match(/^\s*<p\b[^>]*>([\s\S]*)<\/p>\s*$/);
if (!match) return html;
if (/<\/p>\s*<p\b/i.test(match[1])) return html;
return match[1];
const match = html.match(/^\s*<p\b[^>]*>([\s\S]*)<\/p>\s*$/);
if (!match) return html;
if (/<\/p>\s*<p\b/i.test(match[1])) return html;
return match[1];
}
function renderFontFamilyAttr(fontFamily, context) {
if (!fontFamily) {
return "";
}
const resolved = context.resolveFontFamily(fontFamily);
return ` font-family="${resolved}"`;
function renderFontFamilyAttr$3(fontFamily, context) {
if (!fontFamily) return "";
return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
}
// src/renderers/paragraph.ts
//#endregion
//#region src/renderers/paragraph.ts
/**
* Render a paragraph block to MJML markup.
* All text formatting is inline in the HTML content (managed by TipTap).
*/
function renderParagraph(block, _context) {
if (isHiddenOnAll(block)) {
return "";
}
const stripped = block.content.replace(/<\/?p\b[^<>]*>/gi, "").trim();
if (stripped === "") {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const content = convertMergeTagsToValues(block.content);
const visibilityAttr = getCssClassAttr(block);
return `<mj-text
if (isHiddenOnAll(block)) return "";
if (block.content.replace(/<\/?p\b[^<>]*>/gi, "").trim() === "") return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const content = convertMergeTagsToValues(block.content);
return `<mj-text
line-height="1.5"
padding="${padding}"${bgColor}${visibilityAttr}
padding="${padding}"${bgColor}${getCssClassAttr(block)}
>${content}</mj-text>`;
}
// src/renderers/image.ts
//#endregion
//#region src/renderers/image.ts
/**
* Render an image block to MJML markup.
*/
function renderImage(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
if (block.src === "") {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
const visibilityAttr = getCssClassAttr(block);
let linkAttr = "";
if (block.linkUrl) {
linkAttr = ` href="${escapeAttr(block.linkUrl)}"`;
if (block.linkOpenInNewTab) {
linkAttr += ' target="_blank" rel="noopener"';
}
}
const src = escapeAttr(block.src);
const decorative = block.decorative === true;
const alt = decorative ? "" : escapeAttr(block.alt);
const align = block.align;
const roleAttr = decorative ? ' role="presentation"' : "";
return `<mj-image
if (isHiddenOnAll(block)) return "";
if (block.src === "") return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
const visibilityAttr = getCssClassAttr(block);
let linkAttr = "";
if (block.linkUrl) {
linkAttr = ` href="${escapeAttr(block.linkUrl)}"`;
if (block.linkOpenInNewTab) linkAttr += " target=\"_blank\" rel=\"noopener\"";
}
const src = escapeAttr(block.src);
const decorative = block.decorative === true;
return `<mj-image
src="${src}"
alt="${alt}"
alt="${decorative ? "" : escapeAttr(block.alt)}"
width="${width}"
align="${align}"
padding="${padding}"${bgColor}${linkAttr}${visibilityAttr}${roleAttr}
align="${block.align}"
padding="${padding}"${bgColor}${linkAttr}${visibilityAttr}${decorative ? " role=\"presentation\"" : ""}
/>`;
}
// src/renderers/button.ts
//#endregion
//#region src/renderers/button.ts
/**
* Render a button block to MJML markup.
*/
function renderButton(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const buttonPadding = toPaddingString(block.buttonPadding);
const href = block.url === "" ? "" : escapeAttr(block.url);
const hrefAttr = href === "" ? "" : ` href="${href}"`;
const backgroundColor = escapeAttr(block.backgroundColor);
const textColor = escapeAttr(block.textColor);
const fontSize = block.fontSize;
const borderRadius = block.borderRadius;
const text = escapeHtml(block.text);
const targetAttr = block.openInNewTab ? ' target="_blank" rel="noopener"' : "";
const fontFamilyAttr = renderFontFamilyAttr2(block.fontFamily, context);
const visibilityAttr = getCssClassAttr(block);
return `<mj-button${hrefAttr}${targetAttr}
if (isHiddenOnAll(block)) return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const buttonPadding = toPaddingString(block.buttonPadding);
const href = block.url === "" ? "" : escapeAttr(block.url);
const hrefAttr = href === "" ? "" : ` href="${href}"`;
const backgroundColor = escapeAttr(block.backgroundColor);
const textColor = escapeAttr(block.textColor);
const fontSize = block.fontSize;
const borderRadius = block.borderRadius;
const text = escapeHtml(block.text);
return `<mj-button${hrefAttr}${block.openInNewTab ? " target=\"_blank\" rel=\"noopener\"" : ""}
background-color="${backgroundColor}"

@@ -378,118 +323,111 @@ color="${textColor}"

inner-padding="${buttonPadding}"
padding="${padding}"${bgColor}${fontFamilyAttr}${visibilityAttr}
padding="${padding}"${bgColor}${renderFontFamilyAttr$2(block.fontFamily, context)}${getCssClassAttr(block)}
>${text}</mj-button>`;
}
function renderFontFamilyAttr2(fontFamily, context) {
if (!fontFamily) {
return "";
}
const resolved = context.resolveFontFamily(fontFamily);
return ` font-family="${resolved}"`;
function renderFontFamilyAttr$2(fontFamily, context) {
if (!fontFamily) return "";
return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
}
// src/renderers/divider.ts
//#endregion
//#region src/renderers/divider.ts
/**
* Render a divider block to MJML markup.
*/
function renderDivider(block, _context) {
if (isHiddenOnAll(block)) {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
const width = block.width === "full" ? "100%" : block.width + "px";
const thickness = block.thickness;
const lineStyle = block.lineStyle;
const color = escapeAttr(block.color);
const visibilityAttr = getCssClassAttr(block);
return `<mj-divider
border-width="${thickness}px"
border-style="${lineStyle}"
border-color="${color}"
if (isHiddenOnAll(block)) return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
const width = block.width === "full" ? "100%" : block.width + "px";
return `<mj-divider
border-width="${block.thickness}px"
border-style="${block.lineStyle}"
border-color="${escapeAttr(block.color)}"
width="${width}"
padding="${padding}"${bgColor}${visibilityAttr}
padding="${padding}"${bgColor}${getCssClassAttr(block)}
/>`;
}
// src/renderers/spacer.ts
//#endregion
//#region src/renderers/spacer.ts
/**
* Render a spacer block to MJML markup.
*
* The canvas renders a spacer at exactly `block.height` pixels and ignores
* `block.styles.padding`. Match that here: emit `padding="0"` so the
* exported email's spacer occupies the same vertical space the user saw
* in the editor preview. Any non-zero `block.styles.padding` on a spacer
* is meaningless and silently dropped from the export.
*/
function renderSpacer(block, _context) {
if (isHiddenOnAll(block)) {
return "";
}
const height = block.height;
const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
const visibilityAttr = getCssClassAttr(block);
return `<mj-spacer height="${height}px" padding="0"${bgColor}${visibilityAttr} />`;
if (isHiddenOnAll(block)) return "";
return `<mj-spacer height="${block.height}px" padding="0"${block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : ""}${getCssClassAttr(block)} />`;
}
// src/renderers/html.ts
//#endregion
//#region src/renderers/html.ts
/**
* Render an HTML block to MJML markup.
* No sanitization in the OSS version -- consumers are responsible for content safety.
*/
function renderHtml(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
if (!context.allowHtmlBlocks) {
return "";
}
const content = block.content;
if (content === "") {
return "";
}
const visibilityAttr = getCssClassAttr(block);
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
return `<mj-text padding="${padding}"${bgColor}${visibilityAttr}>
if (isHiddenOnAll(block)) return "";
if (!context.allowHtmlBlocks) return "";
const content = block.content;
if (content === "") return "";
const visibilityAttr = getCssClassAttr(block);
return `<mj-text padding="${toPaddingString(block.styles.padding)}"${bgAttr(block.styles.backgroundColor, "container")}${visibilityAttr}>
${content}
</mj-text>`;
}
// src/renderers/social.ts
//#endregion
//#region src/renderers/social.ts
/**
* Render a social icons block to MJML markup.
*
* Icons are emitted as `<img src="…/{style}/{platform}.png">` rather than
* inline SVG or base64 data URIs. Outlook desktop (Word rendering engine)
* does not support SVG and rejects base64 in `<img src>`, so hosted PNGs are
* the only format that renders across every mainstream client. The base URL
* is read from `context.socialIconsBaseUrl` (configurable via
* `RenderOptions.socialIconsBaseUrl`; default is the version-pinned unpkg
* mirror of this package).
*/
function renderSocialIcons(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
const icons = block.icons;
if (icons.length === 0) {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
const visibilityAttr = getCssClassAttr(block);
const align = block.align;
const iconSize = block.iconSize;
const iconStyle = block.iconStyle;
const spacing = block.spacing;
let iconSizePx;
switch (iconSize) {
case "small":
iconSizePx = 24;
break;
case "large":
iconSizePx = 48;
break;
default:
iconSizePx = 32;
break;
}
let borderRadius;
switch (iconStyle) {
case "circle":
borderRadius = "50%";
break;
case "rounded":
borderRadius = "8px";
break;
case "square":
borderRadius = "0";
break;
default:
borderRadius = "4px";
break;
}
const iconCount = icons.length;
const socialElements = icons.map((icon, index) => {
const platform = icon.platform;
const url = escapeAttr(icon.url);
const iconSrc = `${context.socialIconsBaseUrl}/${iconStyle}/${platform}.png`;
const rightPad = index === iconCount - 1 ? 0 : spacing;
return `<mj-social-element src="${iconSrc}" href="${url}" icon-size="${iconSizePx}px" padding="0 ${rightPad}px 0 0" border-radius="${borderRadius}" background-color="transparent"></mj-social-element>`;
});
const socialContent = socialElements.join("\n");
return `<mj-social
if (isHiddenOnAll(block)) return "";
const icons = block.icons;
if (icons.length === 0) return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
const visibilityAttr = getCssClassAttr(block);
const align = block.align;
const iconSize = block.iconSize;
const iconStyle = block.iconStyle;
const spacing = block.spacing;
let iconSizePx;
switch (iconSize) {
case "small":
iconSizePx = 24;
break;
case "large":
iconSizePx = 48;
break;
default:
iconSizePx = 32;
break;
}
let borderRadius;
switch (iconStyle) {
case "circle":
borderRadius = "50%";
break;
case "rounded":
borderRadius = "8px";
break;
case "square":
borderRadius = "0";
break;
default:
borderRadius = "4px";
break;
}
const iconCount = icons.length;
return `<mj-social
mode="horizontal"

@@ -500,281 +438,268 @@ align="${align}"

>
${socialContent}
${icons.map((icon, index) => {
const platform = icon.platform;
const url = escapeAttr(icon.url);
const iconSrc = `${context.socialIconsBaseUrl}/${iconStyle}/${platform}.png`;
const rightPad = index === iconCount - 1 ? 0 : spacing;
return `<mj-social-element src="${iconSrc}" href="${url}" icon-size="${iconSizePx}px" padding="0 ${rightPad}px 0 0" border-radius="${borderRadius}" background-color="transparent"></mj-social-element>`;
}).join("\n")}
</mj-social>`;
}
// src/renderers/menu.ts
//#endregion
//#region src/renderers/menu.ts
/**
* Render a menu block to MJML markup.
* Uses mj-text with inline <a> tags separated by styled <span> separators.
*/
function renderMenu(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
if (block.items.length === 0) {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const visibilityAttr = getCssClassAttr(block);
const fontFamilyAttr = renderFontFamilyAttr3(block.fontFamily, context);
const align = block.textAlign;
const fontSize = block.fontSize;
const color = escapeAttr(block.color);
const content = renderMenuItems(block);
return `<mj-text
font-size="${fontSize}px"
color="${color}"
if (isHiddenOnAll(block)) return "";
if (block.items.length === 0) return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const visibilityAttr = getCssClassAttr(block);
const fontFamilyAttr = renderFontFamilyAttr$1(block.fontFamily, context);
const align = block.textAlign;
return `<mj-text
font-size="${block.fontSize}px"
color="${escapeAttr(block.color)}"
align="${align}"
line-height="1.5"
padding="${padding}"${bgColor}${fontFamilyAttr}${visibilityAttr}
>${content}</mj-text>`;
>${renderMenuItems(block)}</mj-text>`;
}
function renderMenuItems(block) {
const items = block.items;
const separator = escapeHtml(block.separator);
const separatorColor = escapeCssValue(block.separatorColor);
const spacing = block.spacing;
const linkColor = block.linkColor ?? block.color;
const parts = [];
const itemCount = items.length;
for (let index = 0; index < itemCount; index++) {
parts.push(renderMenuItem(items[index], linkColor));
if (index < itemCount - 1) {
parts.push(
`<span style="color: ${separatorColor}; padding: 0 ${spacing}px;">${separator}</span>`
);
}
}
return parts.join("");
const items = block.items;
const separator = escapeHtml(block.separator);
const separatorColor = escapeCssValue(block.separatorColor);
const spacing = block.spacing;
const linkColor = block.linkColor ?? block.color;
const parts = [];
const itemCount = items.length;
for (let index = 0; index < itemCount; index++) {
parts.push(renderMenuItem(items[index], linkColor));
if (index < itemCount - 1) parts.push(`<span style="color: ${separatorColor}; padding: 0 ${spacing}px;">${separator}</span>`);
}
return parts.join("");
}
function renderMenuItem(item, linkColor) {
const text = escapeHtml(item.text);
const url = escapeAttr(item.url);
const color = escapeCssValue(item.color ?? linkColor);
const target = item.openInNewTab ? ' target="_blank" rel="noopener"' : "";
const styles = [`color: ${color}`, "text-decoration: none"];
if (item.bold) {
styles.push("font-weight: bold");
}
if (item.underline) {
styles.push("text-decoration: underline");
}
const styleAttr = styles.join("; ");
return `<a href="${url}" style="${styleAttr}"${target}>${text}</a>`;
const text = escapeHtml(item.text);
const url = escapeAttr(item.url);
const color = escapeCssValue(item.color ?? linkColor);
const target = item.openInNewTab ? " target=\"_blank\" rel=\"noopener\"" : "";
const styles = [`color: ${color}`, "text-decoration: none"];
if (item.bold) styles.push("font-weight: bold");
if (item.underline) styles.push("text-decoration: underline");
return `<a href="${url}" style="${styles.join("; ")}"${target}>${text}</a>`;
}
function renderFontFamilyAttr3(fontFamily, context) {
if (!fontFamily) {
return "";
}
const resolved = context.resolveFontFamily(fontFamily);
return ` font-family="${resolved}"`;
function renderFontFamilyAttr$1(fontFamily, context) {
if (!fontFamily) return "";
return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
}
// src/renderers/table.ts
//#endregion
//#region src/renderers/table.ts
/**
* Render a table block to MJML markup.
* Uses mj-text wrapping an HTML <table> with styled <tr>/<td> elements.
*/
function renderTable(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
if (block.rows.length === 0) {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const visibilityAttr = getCssClassAttr(block);
const fontFamilyAttr = renderFontFamilyAttr4(block.fontFamily, context);
const fontSize = block.fontSize;
const color = escapeAttr(block.color);
const align = block.textAlign;
const tableHtml = renderTableElement(block);
return `<mj-text
font-size="${fontSize}px"
color="${color}"
align="${align}"
if (isHiddenOnAll(block)) return "";
if (block.rows.length === 0) return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const visibilityAttr = getCssClassAttr(block);
const fontFamilyAttr = renderFontFamilyAttr(block.fontFamily, context);
return `<mj-text
font-size="${block.fontSize}px"
color="${escapeAttr(block.color)}"
align="${block.textAlign}"
line-height="1.5"
padding="${padding}"${bgColor}${fontFamilyAttr}${visibilityAttr}
>${tableHtml}</mj-text>`;
>${renderTableElement(block)}</mj-text>`;
}
function renderTableElement(block) {
const borderColor = escapeCssValue(block.borderColor);
const borderWidth = block.borderWidth;
const tableStyle = "width: 100%; border-collapse: collapse;";
let rowsHtml = "";
for (let index = 0; index < block.rows.length; index++) {
const row = block.rows[index];
const isHeader = block.hasHeaderRow && index === 0;
rowsHtml += renderRow(row, block, isHeader, borderColor, borderWidth);
}
return `<table style="${tableStyle}">${rowsHtml}</table>`;
const borderColor = escapeCssValue(block.borderColor);
const borderWidth = block.borderWidth;
const tableStyle = "width: 100%; border-collapse: collapse;";
let rowsHtml = "";
for (let index = 0; index < block.rows.length; index++) {
const row = block.rows[index];
const isHeader = block.hasHeaderRow && index === 0;
rowsHtml += renderRow(row, block, isHeader, borderColor, borderWidth);
}
return `<table style="${tableStyle}">${rowsHtml}</table>`;
}
function renderRow(row, block, isHeader, borderColor, borderWidth) {
let cellsHtml = "";
for (const cell of row.cells) {
cellsHtml += renderCell(cell, block, isHeader, borderColor, borderWidth);
}
return `<tr>${cellsHtml}</tr>`;
let cellsHtml = "";
for (const cell of row.cells) cellsHtml += renderCell(cell, block, isHeader, borderColor, borderWidth);
return `<tr>${cellsHtml}</tr>`;
}
function renderCell(cell, block, isHeader, borderColor, borderWidth) {
const cellPadding = block.cellPadding;
const styles = [
`border: ${borderWidth}px solid ${borderColor}`,
`padding: ${cellPadding}px`
];
if (isHeader) {
styles.push("font-weight: bold");
if (block.headerBackgroundColor) {
styles.push(
`background-color: ${escapeCssValue(block.headerBackgroundColor)}`
);
}
}
const styleAttr = styles.join("; ");
const content = convertMergeTagsToValues(cell.content);
const tag = isHeader ? "th" : "td";
return `<${tag} style="${styleAttr}">${content}</${tag}>`;
const cellPadding = block.cellPadding;
const styles = [`border: ${borderWidth}px solid ${borderColor}`, `padding: ${cellPadding}px`];
if (isHeader) {
styles.push("font-weight: bold");
if (block.headerBackgroundColor) styles.push(`background-color: ${escapeCssValue(block.headerBackgroundColor)}`);
}
const styleAttr = styles.join("; ");
const content = convertMergeTagsToValues(cell.content);
const tag = isHeader ? "th" : "td";
return `<${tag} style="${styleAttr}">${content}</${tag}>`;
}
function renderFontFamilyAttr4(fontFamily, context) {
if (!fontFamily) {
return "";
}
const resolved = context.resolveFontFamily(fontFamily);
return ` font-family="${resolved}"`;
function renderFontFamilyAttr(fontFamily, context) {
if (!fontFamily) return "";
return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
}
// src/renderers/custom.ts
//#endregion
//#region src/renderers/custom.ts
/**
* Render a custom block to MJML markup.
*
* Custom block HTML resolution order:
* 1. `context.customBlockHtml` map — populated by `renderToMjml` when the
* caller passes a `renderCustomBlock` option (typical for editor
* consumers and headless callers wiring their own resolver).
* 2. `block.renderedHtml` — populated by an external pre-render step
* (e.g., a previous render pass that mutated the block).
* 3. Empty — block omitted from output.
*/
function renderCustom(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
const fromContext = context.customBlockHtml.get(block.id);
const content = fromContext ?? block.renderedHtml;
if (!content || content === "") {
return "";
}
const visibilityAttr = getCssClassAttr(block);
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
return `<mj-text padding="${padding}"${bgColor}${visibilityAttr}>
if (isHiddenOnAll(block)) return "";
const content = context.customBlockHtml.get(block.id) ?? block.renderedHtml;
if (!content || content === "") return "";
const visibilityAttr = getCssClassAttr(block);
return `<mj-text padding="${toPaddingString(block.styles.padding)}"${bgAttr(block.styles.backgroundColor, "container")}${visibilityAttr}>
${content}
</mj-text>`;
}
// src/renderers/section.ts
import { isSection } from "@templatical/types";
// src/columns.ts
//#endregion
//#region src/columns.ts
/**
* Get width percentages for each column in a layout.
*/
function getWidthPercentages(layout) {
switch (layout) {
case "2":
return ["50%", "50%"];
case "3":
return ["33.33%", "33.33%", "33.34%"];
case "1-2":
return ["33.33%", "66.67%"];
case "2-1":
return ["66.67%", "33.33%"];
default:
return ["100%"];
}
switch (layout) {
case "2": return ["50%", "50%"];
case "3": return [
"33.33%",
"33.33%",
"33.34%"
];
case "1-2": return ["33.33%", "66.67%"];
case "2-1": return ["66.67%", "33.33%"];
default: return ["100%"];
}
}
/**
* Get width in pixels for each column in a layout.
*/
function getWidthPixels(layout, containerWidth) {
switch (layout) {
case "2":
return [containerWidth * 0.5, containerWidth * 0.5];
case "3":
return [containerWidth / 3, containerWidth / 3, containerWidth / 3];
case "1-2":
return [containerWidth / 3, containerWidth * 2 / 3];
case "2-1":
return [containerWidth * 2 / 3, containerWidth / 3];
default:
return [containerWidth];
}
switch (layout) {
case "2": return [containerWidth * .5, containerWidth * .5];
case "3": return [
containerWidth / 3,
containerWidth / 3,
containerWidth / 3
];
case "1-2": return [containerWidth / 3, containerWidth * 2 / 3];
case "2-1": return [containerWidth * 2 / 3, containerWidth / 3];
default: return [containerWidth];
}
}
// src/renderers/section.ts
function renderSection(block, context, renderBlock2) {
if (isHiddenOnAll(block)) {
return "";
}
const columnsLayout = block.columns;
const columnWidths = getWidthPercentages(columnsLayout);
const columnWidthsPx = getWidthPixels(columnsLayout, context.containerWidth);
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "native");
const visibilityAttr = getCssClassAttr(block);
const children = block.children;
const columnsContent = [];
for (let index = 0; index < children.length; index++) {
const column = children[index];
const width = columnWidths[index] ?? "100%";
const columnWidth = Math.floor(
columnWidthsPx[index] ?? context.containerWidth
);
const filteredColumn = filterHtmlBlocks(
column,
context.allowHtmlBlocks
).filter((child) => !isSection(child));
const columnContext = context.withContainerWidth(columnWidth);
const columnBlocks = filteredColumn.map((child) => renderBlock2(child, columnContext)).filter((value) => value !== "").join("\n");
const content = columnBlocks === "" ? "<mj-text>&nbsp;</mj-text>" : columnBlocks;
columnsContent.push(`<mj-column width="${width}">
//#endregion
//#region src/display-condition.ts
/**
* Wrap rendered block markup in the block's liquid display-condition guards
* (`<mj-raw>{% if %}</mj-raw>` … `<mj-raw>{% endif %}</mj-raw>`), if present.
*
* Returns the input unchanged when the block has no display condition, and an
* empty string when the rendered markup is empty (a hidden block) so callers
* can keep using an `=== ""` filter to drop it.
*
* Used for BOTH top-level blocks (`index.ts`) and blocks nested inside section
* columns (`renderers/section.ts`). A condition on a nested block must emit the
* same guards as a top-level one — otherwise conditional content placed inside
* a multi-column section renders unconditionally for every recipient.
*/
function wrapWithDisplayCondition(block, rendered) {
if (rendered === "") return "";
const displayCondition = block.displayCondition;
if (!displayCondition) return rendered;
return `<mj-raw>${displayCondition.before}</mj-raw>
` + rendered + `
<mj-raw>${displayCondition.after}</mj-raw>`;
}
//#endregion
//#region src/renderers/section.ts
/**
* Render a section block with columns to MJML markup.
*/
function renderSection(block, context, renderBlock) {
if (isHiddenOnAll(block)) return "";
const columnsLayout = block.columns;
const columnWidths = getWidthPercentages(columnsLayout);
const columnWidthsPx = getWidthPixels(columnsLayout, context.containerWidth);
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "native");
const visibilityAttr = getCssClassAttr(block);
const children = block.children;
const columnsContent = [];
for (let index = 0; index < children.length; index++) {
const column = children[index];
const width = columnWidths[index] ?? "100%";
const columnWidth = Math.floor(columnWidthsPx[index] ?? context.containerWidth);
const filteredColumn = filterHtmlBlocks$1(column, context.allowHtmlBlocks).filter((child) => !isSection(child));
const columnContext = context.withContainerWidth(columnWidth);
const columnBlocks = filteredColumn.map((child) => wrapWithDisplayCondition(child, renderBlock(child, columnContext))).filter((value) => value !== "").join("\n");
const content = columnBlocks === "" ? "<mj-text>&nbsp;</mj-text>" : columnBlocks;
columnsContent.push(`<mj-column width="${width}">
${content}
</mj-column>`);
}
const columns = columnsContent.join("\n");
return `<mj-section${bgColor} padding="${padding}"${visibilityAttr}>
${columns}
}
return `<mj-section${bgColor} padding="${padding}"${visibilityAttr}>
${columnsContent.join("\n")}
</mj-section>`;
}
function filterHtmlBlocks(blocks, allowHtmlBlocks) {
if (allowHtmlBlocks) {
return blocks;
}
return blocks.filter((block) => block.type !== "html");
/**
* Filter out HTML blocks if they are not allowed.
*/
function filterHtmlBlocks$1(blocks, allowHtmlBlocks) {
if (allowHtmlBlocks) return blocks;
return blocks.filter((block) => block.type !== "html");
}
// src/renderers/video.ts
//#endregion
//#region src/renderers/video.ts
/**
* Extract video thumbnail URL from common platforms.
* Works without server-side processing — YouTube and Vimeo thumbnails are publicly accessible.
*/
function getVideoThumbnail(url, customThumbnail) {
if (customThumbnail) {
return customThumbnail;
}
if (!url) {
return null;
}
const youtubePatterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/
];
for (const pattern of youtubePatterns) {
const match = url.match(pattern);
if (match) {
return `https://img.youtube.com/vi/${match[1]}/maxresdefault.jpg`;
}
}
const vimeoMatch = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
if (vimeoMatch) {
return `https://vumbnail.com/${vimeoMatch[1]}.jpg`;
}
return null;
if (customThumbnail) return customThumbnail;
if (!url) return null;
for (const pattern of [/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/, /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/]) {
const match = url.match(pattern);
if (match) return `https://img.youtube.com/vi/${match[1]}/maxresdefault.jpg`;
}
const vimeoMatch = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
if (vimeoMatch) return `https://vumbnail.com/${vimeoMatch[1]}.jpg`;
return null;
}
/**
* Render a video block to MJML markup.
* Videos in email are rendered as a linked thumbnail image pointing to the video URL.
*/
function renderVideo(block, context) {
if (isHiddenOnAll(block)) {
return "";
}
const thumbnailUrl = getVideoThumbnail(block.url, block.thumbnailUrl);
if (!thumbnailUrl) {
return "";
}
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
const visibilityAttr = getCssClassAttr(block);
const src = escapeAttr(thumbnailUrl);
const alt = escapeAttr(block.alt);
const align = block.align;
const href = escapeAttr(block.url);
return `<mj-image
src="${src}"
alt="${alt}"
if (isHiddenOnAll(block)) return "";
const thumbnailUrl = getVideoThumbnail(block.url, block.thumbnailUrl);
if (!thumbnailUrl) return "";
const padding = toPaddingString(block.styles.padding);
const bgColor = bgAttr(block.styles.backgroundColor, "container");
const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
const visibilityAttr = getCssClassAttr(block);
return `<mj-image
src="${escapeAttr(thumbnailUrl)}"
alt="${escapeAttr(block.alt)}"
width="${width}"
align="${align}"
align="${block.align}"
padding="${padding}"
href="${href}"
href="${escapeAttr(block.url)}"
target="_blank"

@@ -784,81 +709,50 @@ rel="noopener"${bgColor}${visibilityAttr}

}
// src/renderers/index.ts
//#endregion
//#region src/renderers/index.ts
/**
* Render a single block to MJML markup.
* Dispatches to the appropriate block-type renderer.
*/
function renderBlock(block, context) {
if (isSection2(block)) {
return renderSection(block, context, renderBlock);
}
if (isTitle(block)) {
return renderTitle(block, context);
}
if (isParagraph(block)) {
return renderParagraph(block, context);
}
if (isImage(block)) {
return renderImage(block, context);
}
if (isButton(block)) {
return renderButton(block, context);
}
if (isDivider(block)) {
return renderDivider(block, context);
}
if (isSpacer(block)) {
return renderSpacer(block, context);
}
if (isHtml(block)) {
return renderHtml(block, context);
}
if (isSocialIcons(block)) {
return renderSocialIcons(block, context);
}
if (isMenu(block)) {
return renderMenu(block, context);
}
if (isTable(block)) {
return renderTable(block, context);
}
if (isVideo(block)) {
return renderVideo(block, context);
}
if (isCustomBlock(block)) {
return renderCustom(block, context);
}
return "";
if (isSection(block)) return renderSection(block, context, renderBlock);
if (isTitle(block)) return renderTitle(block, context);
if (isParagraph(block)) return renderParagraph(block, context);
if (isImage(block)) return renderImage(block, context);
if (isButton(block)) return renderButton(block, context);
if (isDivider(block)) return renderDivider(block, context);
if (isSpacer(block)) return renderSpacer(block, context);
if (isHtml(block)) return renderHtml(block, context);
if (isSocialIcons(block)) return renderSocialIcons(block, context);
if (isMenu(block)) return renderMenu(block, context);
if (isTable(block)) return renderTable(block, context);
if (isVideo(block)) return renderVideo(block, context);
if (isCustomBlock(block)) return renderCustom(block, context);
return "";
}
// src/index.ts
//#endregion
//#region src/index.ts
/**
* Render template content to an MJML string.
*
* The function is async because resolving custom blocks may require
* asynchronous work (e.g., the editor's liquid renderer dynamically imports
* `liquidjs`). When the content has no custom blocks or `renderCustomBlock`
* is omitted, no async work is performed but the function still resolves
* synchronously — i.e., it always returns a Promise.
*/
async function renderToMjml(content, options) {
const customFonts = options?.customFonts ?? [];
const defaultFallbackFont = options?.defaultFallbackFont ?? "Arial, sans-serif";
const allowHtmlBlocks = options?.allowHtmlBlocks ?? true;
const socialIconsBaseUrl = stripTrailingSlash(
options?.socialIconsBaseUrl ?? DEFAULT_SOCIAL_ICONS_BASE_URL
);
const customBlockHtml = await resolveCustomBlocks(
content,
options?.renderCustomBlock
);
const customBlockStylesheets = collectCustomBlockStylesheets(
content,
options?.getCustomBlockStylesheet
);
const renderContext = new RenderContext(
content.settings.width,
customFonts,
defaultFallbackFont,
allowHtmlBlocks,
customBlockHtml,
socialIconsBaseUrl
);
const blocks = filterHtmlBlocks2(content.blocks, allowHtmlBlocks);
const fontFamily = renderContext.resolveFontFamily(
content.settings.fontFamily
);
const backgroundColor = content.settings.backgroundColor;
const bodyContent = blocks.map((block) => renderTopLevelBlock(block, renderContext)).filter((value) => value !== "").join("\n");
const fontDeclarations = generateFontDeclarations(customFonts);
const previewTag = generatePreviewTag(content.settings.preheaderText);
const lang = escapeAttr(content.settings.locale);
return `<mjml lang="${lang}">
const customFonts = options?.customFonts ?? [];
const defaultFallbackFont = options?.defaultFallbackFont ?? "Arial, sans-serif";
const allowHtmlBlocks = options?.allowHtmlBlocks ?? true;
const socialIconsBaseUrl = stripTrailingSlash(options?.socialIconsBaseUrl ?? DEFAULT_SOCIAL_ICONS_BASE_URL);
const customBlockHtml = await resolveCustomBlocks(content, options?.renderCustomBlock);
const customBlockStylesheets = collectCustomBlockStylesheets(content, options?.getCustomBlockStylesheet);
const renderContext = new RenderContext(content.settings.width, customFonts, defaultFallbackFont, allowHtmlBlocks, customBlockHtml, socialIconsBaseUrl);
const blocks = filterHtmlBlocks(content.blocks, allowHtmlBlocks);
const fontFamily = renderContext.resolveFontFamily(content.settings.fontFamily);
const backgroundColor = content.settings.backgroundColor;
const bodyContent = blocks.map((block) => renderTopLevelBlock(block, renderContext)).filter((value) => value !== "").join("\n");
const fontDeclarations = generateFontDeclarations(customFonts);
const previewTag = generatePreviewTag(content.settings.preheaderText);
return `<mjml lang="${escapeAttr(content.settings.locale)}">
<mj-head>${previewTag}

@@ -887,28 +781,16 @@ <mj-attributes>

}
/**
* Render a top-level block. Sections are rendered directly,
* non-section blocks are wrapped in a default section/column.
*/
function renderTopLevelBlock(block, context) {
if (isSection3(block)) {
const rendered = renderBlock(block, context);
return wrapWithDisplayCondition(block, rendered);
}
const content = renderBlock(block, context);
const wrapped = wrapInSection(content);
return wrapWithDisplayCondition(block, wrapped);
if (isSection(block)) return wrapWithDisplayCondition(block, renderBlock(block, context));
return wrapWithDisplayCondition(block, wrapInSection(renderBlock(block, context)));
}
function wrapWithDisplayCondition(block, rendered) {
if (rendered === "") {
return "";
}
const displayCondition = block.displayCondition;
if (!displayCondition) {
return rendered;
}
return `<mj-raw>${displayCondition.before}</mj-raw>
` + rendered + `
<mj-raw>${displayCondition.after}</mj-raw>`;
}
/**
* Wrap block content in a default mj-section/mj-column for non-section blocks.
*/
function wrapInSection(content) {
if (content === "") {
return "";
}
return `<mj-section>
if (content === "") return "";
return `<mj-section>
<mj-column>

@@ -920,116 +802,87 @@ ${content}

function stripTrailingSlash(url) {
return url.endsWith("/") ? url.slice(0, -1) : url;
return url.endsWith("/") ? url.slice(0, -1) : url;
}
function generatePreviewTag(preheaderText) {
if (!preheaderText) {
return "";
}
const trimmed = preheaderText.trim();
if (trimmed === "") {
return "";
}
const escaped = escapeHtml(trimmed);
return `
<mj-preview>${escaped}</mj-preview>`;
if (!preheaderText) return "";
const trimmed = preheaderText.trim();
if (trimmed === "") return "";
return `\n <mj-preview>${escapeHtml(trimmed)}</mj-preview>`;
}
function generateFontDeclarations(customFonts) {
if (customFonts.length === 0) {
return "";
}
return customFonts.map(
(font) => `
<mj-font name="${escapeAttr(font.name)}" href="${escapeAttr(font.url)}" />`
).join("");
if (customFonts.length === 0) return "";
return customFonts.map((font) => `\n <mj-font name="${escapeAttr(font.name)}" href="${escapeAttr(font.url)}" />`).join("");
}
function filterHtmlBlocks2(blocks, allowHtmlBlocks) {
if (allowHtmlBlocks) {
return blocks;
}
return blocks.filter((block) => block.type !== "html");
/**
* Filter out HTML blocks if they are not allowed.
*/
function filterHtmlBlocks(blocks, allowHtmlBlocks) {
if (allowHtmlBlocks) return blocks;
return blocks.filter((block) => block.type !== "html");
}
/**
* Walk the content tree, collect every custom block, then resolve each in
* parallel via the supplied callback. Returns a map keyed by block id that
* the synchronous render pass reads from. If no callback is provided, returns
* an empty map and the sync pass falls back to `block.renderedHtml`.
*
* Per-block failures bubble up — the caller decides whether to swallow or
* rethrow. We don't replace failures with placeholders here because that's
* a policy decision (the editor swallows; a strict CLI may want to fail).
*/
async function resolveCustomBlocks(content, renderCustomBlock) {
const result = /* @__PURE__ */ new Map();
if (!renderCustomBlock) {
return result;
}
const customBlocks = [];
collectCustomBlocks(content.blocks, customBlocks);
if (customBlocks.length === 0) {
return result;
}
const rendered = await Promise.all(
customBlocks.map((block) => renderCustomBlock(block))
);
for (let index = 0; index < customBlocks.length; index++) {
result.set(customBlocks[index].id, rendered[index]);
}
return result;
const result = /* @__PURE__ */ new Map();
if (!renderCustomBlock) return result;
const customBlocks = [];
collectCustomBlocks(content.blocks, customBlocks);
if (customBlocks.length === 0) return result;
const rendered = await Promise.all(customBlocks.map((block) => renderCustomBlock(block)));
for (let index = 0; index < customBlocks.length; index++) result.set(customBlocks[index].id, rendered[index]);
return result;
}
function collectCustomBlocks(blocks, out) {
for (const block of blocks) {
if (isCustomBlock2(block)) {
out.push(block);
continue;
}
if (isSection3(block)) {
for (const column of block.children) {
collectCustomBlocks(column, out);
}
}
}
for (const block of blocks) {
if (isCustomBlock(block)) {
out.push(block);
continue;
}
if (isSection(block)) for (const column of block.children) collectCustomBlocks(column, out);
}
}
/**
* Walk the content tree, find every unique `customType`, ask the consumer's
* resolver for that definition's stylesheet, and return the non-empty,
* content-deduped set in insertion order.
*
* Content-level dedupe (not just by customType) means two definitions that
* happen to ship the same stylesheet string emit it only once — cheap and
* matches the "one rule, emitted once" mental model. Whitespace-only and
* empty stylesheets are skipped.
*/
function collectCustomBlockStylesheets(content, resolver) {
if (!resolver) {
return [];
}
const customBlocks = [];
collectCustomBlocks(content.blocks, customBlocks);
if (customBlocks.length === 0) {
return [];
}
const seenTypes = /* @__PURE__ */ new Set();
const seenContent = /* @__PURE__ */ new Set();
const stylesheets = [];
for (const block of customBlocks) {
if (seenTypes.has(block.customType)) {
continue;
}
seenTypes.add(block.customType);
const css = resolver(block.customType);
if (!css) {
continue;
}
const trimmed = css.trim();
if (trimmed === "" || seenContent.has(trimmed)) {
continue;
}
seenContent.add(trimmed);
stylesheets.push(trimmed);
}
return stylesheets;
if (!resolver) return [];
const customBlocks = [];
collectCustomBlocks(content.blocks, customBlocks);
if (customBlocks.length === 0) return [];
const seenTypes = /* @__PURE__ */ new Set();
const seenContent = /* @__PURE__ */ new Set();
const stylesheets = [];
for (const block of customBlocks) {
if (seenTypes.has(block.customType)) continue;
seenTypes.add(block.customType);
const css = resolver(block.customType);
if (!css) continue;
const trimmed = css.trim();
if (trimmed === "" || seenContent.has(trimmed)) continue;
seenContent.add(trimmed);
stylesheets.push(trimmed);
}
return stylesheets;
}
function renderCustomBlockStylesheets(stylesheets) {
if (stylesheets.length === 0) {
return "";
}
return stylesheets.map((css) => `
<mj-style>
${css}
</mj-style>`).join("");
if (stylesheets.length === 0) return "";
return stylesheets.map((css) => `\n <mj-style>\n${css}\n </mj-style>`).join("");
}
export {
DEFAULT_SOCIAL_ICONS_BASE_URL,
RenderContext,
convertMergeTagsToValues,
escapeAttr,
escapeHtml,
getCssClassAttr,
getCssClasses,
getWidthPercentages,
getWidthPixels,
isHiddenOnAll,
renderBlock,
renderToMjml,
toPaddingString
};
//#endregion
export { DEFAULT_SOCIAL_ICONS_BASE_URL, RenderContext, convertMergeTagsToValues, escapeAttr, escapeHtml, getCssClassAttr, getCssClasses, getWidthPercentages, getWidthPixels, isHiddenOnAll, renderBlock, renderToMjml, toPaddingString };
//# sourceMappingURL=index.js.map
{
"name": "@templatical/renderer",
"description": "Render Templatical email templates to MJML",
"version": "0.10.0",
"version": "0.10.1",
"bugs": "https://github.com/templatical/sdk/issues",
"dependencies": {
"@templatical/types": "0.10.0"
"@templatical/types": "0.10.1"
},
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"mjml": "^5.2.2",
"tsup": "^8.5.1",
"mjml": "^5.3.0",
"typescript": "^6.0.3",

@@ -48,3 +47,3 @@ "vitest": "^4.1.7"

"scripts": {
"build": "tsup && node scripts/rasterize-social.mjs",
"build": "tsdown && node scripts/rasterize-social.mjs",
"test": "vitest run --config vitest.config.ts",

@@ -51,0 +50,0 @@ "typecheck": "tsc --noEmit"

Sorry, the diff of this file is too big to display