@templatical/renderer 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,284 +1,234 @@
1
- // src/index.ts
2
- import { isSection as isSection3, isCustomBlock as isCustomBlock2 } from "@templatical/types";
3
-
4
- // package.json
5
- var package_default = {
6
- name: "@templatical/renderer",
7
- description: "Render Templatical email templates to MJML",
8
- version: "0.9.1",
9
- bugs: "https://github.com/templatical/sdk/issues",
10
- dependencies: {
11
- "@templatical/types": "workspace:*"
12
- },
13
- devDependencies: {
14
- "@resvg/resvg-js": "^2.6.2",
15
- mjml: "^5.2.2",
16
- tsup: "^8.5.1",
17
- typescript: "^6.0.3",
18
- vitest: "^4.1.7"
19
- },
20
- exports: {
21
- ".": {
22
- types: "./dist/index.d.ts",
23
- import: "./dist/index.js"
24
- }
25
- },
26
- files: [
27
- "dist",
28
- "assets"
29
- ],
30
- homepage: "https://templatical.com",
31
- keywords: [
32
- "email",
33
- "email-template",
34
- "html-email",
35
- "mjml",
36
- "renderer",
37
- "templatical"
38
- ],
39
- license: "MIT",
40
- module: "./dist/index.js",
41
- publishConfig: {
42
- access: "public"
43
- },
44
- repository: {
45
- type: "git",
46
- url: "git+https://github.com/templatical/sdk.git",
47
- directory: "packages/renderer"
48
- },
49
- scripts: {
50
- build: "tsup && node scripts/rasterize-social.mjs",
51
- test: "vitest run --config vitest.config.ts",
52
- typecheck: "tsc --noEmit"
53
- },
54
- type: "module",
55
- types: "./dist/index.d.ts"
1
+ import { HEADING_LEVEL_FONT_SIZE, isButton, isCustomBlock, isDivider, isHtml, isImage, isMenu, isParagraph, isSection, isSocialIcons, isSpacer, isTable, isTitle, isVideo } from "@templatical/types";
2
+ //#endregion
3
+ //#region src/render-context.ts
4
+ const DEFAULT_SOCIAL_ICONS_BASE_URL = `https://unpkg.com/@templatical/renderer@0.10.1/assets/social`;
5
+ const BUILT_IN_FONT_FALLBACKS = {
6
+ arial: "Arial, sans-serif",
7
+ helvetica: "Helvetica, sans-serif",
8
+ georgia: "Georgia, serif",
9
+ "times new roman": "'Times New Roman', serif",
10
+ verdana: "Verdana, sans-serif",
11
+ "trebuchet ms": "'Trebuchet MS', sans-serif",
12
+ "courier new": "'Courier New', monospace",
13
+ tahoma: "Tahoma, sans-serif"
56
14
  };
57
-
58
- // src/render-context.ts
59
- var DEFAULT_SOCIAL_ICONS_BASE_URL = `https://unpkg.com/@templatical/renderer@${package_default.version}/assets/social`;
60
- var BUILT_IN_FONT_FALLBACKS = {
61
- arial: "Arial, sans-serif",
62
- helvetica: "Helvetica, sans-serif",
63
- georgia: "Georgia, serif",
64
- "times new roman": "'Times New Roman', serif",
65
- verdana: "Verdana, sans-serif",
66
- "trebuchet ms": "'Trebuchet MS', sans-serif",
67
- "courier new": "'Courier New', monospace",
68
- tahoma: "Tahoma, sans-serif"
15
+ /**
16
+ * Immutable context passed through the block rendering chain.
17
+ */
18
+ var RenderContext = class RenderContext {
19
+ containerWidth;
20
+ customFonts;
21
+ defaultFallbackFont;
22
+ allowHtmlBlocks;
23
+ customBlockHtml;
24
+ socialIconsBaseUrl;
25
+ constructor(containerWidth, customFonts, defaultFallbackFont, allowHtmlBlocks, customBlockHtml = /* @__PURE__ */ new Map(), socialIconsBaseUrl = DEFAULT_SOCIAL_ICONS_BASE_URL) {
26
+ this.containerWidth = containerWidth;
27
+ this.customFonts = customFonts;
28
+ this.defaultFallbackFont = defaultFallbackFont;
29
+ this.allowHtmlBlocks = allowHtmlBlocks;
30
+ this.customBlockHtml = customBlockHtml;
31
+ this.socialIconsBaseUrl = socialIconsBaseUrl;
32
+ }
33
+ /**
34
+ * Create a new context with a different container width.
35
+ * Used when rendering columns with narrower widths.
36
+ */
37
+ withContainerWidth(width) {
38
+ return new RenderContext(width, this.customFonts, this.defaultFallbackFont, this.allowHtmlBlocks, this.customBlockHtml, this.socialIconsBaseUrl);
39
+ }
40
+ /**
41
+ * Resolve a font family name to include custom font fallbacks.
42
+ * If the font matches a custom font, returns `'FontName', fallback`.
43
+ * Otherwise returns the original font family string.
44
+ */
45
+ resolveFontFamily(fontFamily) {
46
+ for (const customFont of this.customFonts) if (customFont.name.toLowerCase() === fontFamily.toLowerCase()) {
47
+ const fallback = customFont.fallback ?? this.defaultFallbackFont;
48
+ return `'${customFont.name}', ${fallback}`;
49
+ }
50
+ const builtIn = BUILT_IN_FONT_FALLBACKS[fontFamily.toLowerCase()];
51
+ if (builtIn) return builtIn;
52
+ return fontFamily;
53
+ }
69
54
  };
70
- var RenderContext = class _RenderContext {
71
- constructor(containerWidth, customFonts, defaultFallbackFont, allowHtmlBlocks, customBlockHtml = /* @__PURE__ */ new Map(), socialIconsBaseUrl = DEFAULT_SOCIAL_ICONS_BASE_URL) {
72
- this.containerWidth = containerWidth;
73
- this.customFonts = customFonts;
74
- this.defaultFallbackFont = defaultFallbackFont;
75
- this.allowHtmlBlocks = allowHtmlBlocks;
76
- this.customBlockHtml = customBlockHtml;
77
- this.socialIconsBaseUrl = socialIconsBaseUrl;
78
- }
79
- containerWidth;
80
- customFonts;
81
- defaultFallbackFont;
82
- allowHtmlBlocks;
83
- customBlockHtml;
84
- socialIconsBaseUrl;
85
- /**
86
- * Create a new context with a different container width.
87
- * Used when rendering columns with narrower widths.
88
- */
89
- withContainerWidth(width) {
90
- return new _RenderContext(
91
- width,
92
- this.customFonts,
93
- this.defaultFallbackFont,
94
- this.allowHtmlBlocks,
95
- this.customBlockHtml,
96
- this.socialIconsBaseUrl
97
- );
98
- }
99
- /**
100
- * Resolve a font family name to include custom font fallbacks.
101
- * If the font matches a custom font, returns `'FontName', fallback`.
102
- * Otherwise returns the original font family string.
103
- */
104
- resolveFontFamily(fontFamily) {
105
- for (const customFont of this.customFonts) {
106
- if (customFont.name.toLowerCase() === fontFamily.toLowerCase()) {
107
- const fallback = customFont.fallback ?? this.defaultFallbackFont;
108
- return `'${customFont.name}', ${fallback}`;
109
- }
110
- }
111
- const builtIn = BUILT_IN_FONT_FALLBACKS[fontFamily.toLowerCase()];
112
- if (builtIn) {
113
- return builtIn;
114
- }
115
- return fontFamily;
116
- }
55
+ //#endregion
56
+ //#region src/escape.ts
57
+ const HTML_ENTITIES = {
58
+ "&": "&",
59
+ "<": "&lt;",
60
+ ">": "&gt;",
61
+ "\"": "&quot;",
62
+ "'": "&#039;"
117
63
  };
118
-
119
- // src/renderers/index.ts
120
- import {
121
- isSection as isSection2,
122
- isTitle,
123
- isParagraph,
124
- isImage,
125
- isButton,
126
- isDivider,
127
- isSpacer,
128
- isHtml,
129
- isSocialIcons,
130
- isMenu,
131
- isTable,
132
- isVideo,
133
- isCustomBlock
134
- } from "@templatical/types";
135
-
136
- // src/renderers/title.ts
137
- import { HEADING_LEVEL_FONT_SIZE } from "@templatical/types";
138
-
139
- // src/escape.ts
140
- var HTML_ENTITIES = {
141
- "&": "&amp;",
142
- "<": "&lt;",
143
- ">": "&gt;",
144
- '"': "&quot;",
145
- "'": "&#039;"
146
- };
147
- var HTML_ENTITY_REGEX = /[&<>"']/g;
64
+ const HTML_ENTITY_REGEX = /[&<>"']/g;
65
+ /**
66
+ * Escape HTML special characters (& < > " ').
67
+ * Equivalent to PHP htmlspecialchars with ENT_QUOTES | ENT_HTML5.
68
+ */
148
69
  function escapeHtml(text) {
149
- if (text === "") {
150
- return "";
151
- }
152
- return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
70
+ if (text === "") return "";
71
+ return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
153
72
  }
73
+ /**
74
+ * Escape a string for use in an HTML attribute value.
75
+ * Same implementation as escapeHtml for consistency with PHP.
76
+ */
154
77
  function escapeAttr(text) {
155
- if (text === "") {
156
- return "";
157
- }
158
- return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
159
- }
78
+ if (text === "") return "";
79
+ return text.replace(HTML_ENTITY_REGEX, (char) => HTML_ENTITIES[char] ?? char);
80
+ }
81
+ /**
82
+ * Escape a string for use as a CSS property value inside an inline
83
+ * `style="prop: ${value}"` attribute. Beyond HTML entity escaping (so the
84
+ * value survives the attribute boundary), this strips characters that
85
+ * could break out of the property value into a sibling property:
86
+ *
87
+ * `;` — separates CSS declarations
88
+ * `{`/`}` — opens/closes a CSS rule (rejected by attribute parsers but
89
+ * still safer to remove)
90
+ * `\n`/`\r` — would smuggle past line-based CSS sanitizers
91
+ *
92
+ * Without this, an attacker-controlled color like
93
+ * `"red; background: url('//attacker/log')"` lands as a real CSS rule.
94
+ */
160
95
  function escapeCssValue(text) {
161
- if (text === "") {
162
- return "";
163
- }
164
- return escapeAttr(text).replace(/[;{}\r\n]/g, "");
165
- }
96
+ if (text === "") return "";
97
+ return escapeAttr(text).replace(/[;{}\r\n]/g, "");
98
+ }
99
+ /**
100
+ * Replace merge tag span elements with their data attribute values.
101
+ * Converts `<span data-merge-tag="{{name}}">Label</span>` to `{{name}}`.
102
+ * Also handles `data-logic-merge-tag` attributes.
103
+ *
104
+ * Uses a single-pass linear scan instead of an `[^>]*…[^>]*` regex because
105
+ * the latter is polynomial-ReDoS over inputs that contain many `<span`
106
+ * starts but no closing `>` — the engine retries `[^>]*` at every span
107
+ * position. The scan below resolves each `<span>` open tag with a bounded
108
+ * `indexOf('>')`, keeping the work strictly O(n).
109
+ */
166
110
  function convertMergeTagsToValues(html) {
167
- if (html === "") {
168
- return "";
169
- }
170
- return rewriteMergeTagSpans(
171
- html,
172
- (attrs) => findAttr(attrs, "data-merge-tag") ?? findAttr(attrs, "data-logic-merge-tag")
173
- );
174
- }
111
+ if (html === "") return "";
112
+ return rewriteMergeTagSpans(html, (attrs) => findAttr(attrs, "data-merge-tag") ?? findAttr(attrs, "data-logic-merge-tag"));
113
+ }
114
+ /**
115
+ * Walk `html`, find every `<span …>…</span>`, and replace the entire span
116
+ * with whatever `extract` returns for its attribute string (or leave it
117
+ * alone if `extract` returns `null`). Linear in the length of `html`:
118
+ * every `indexOf` advances the cursor monotonically.
119
+ */
175
120
  function rewriteMergeTagSpans(html, extract) {
176
- let out = "";
177
- let i = 0;
178
- while (i < html.length) {
179
- const open = html.indexOf("<span", i);
180
- if (open === -1) {
181
- out += html.substring(i);
182
- break;
183
- }
184
- const afterTagName = html[open + 5];
185
- if (afterTagName !== ">" && afterTagName !== " " && afterTagName !== " " && afterTagName !== "\n" && afterTagName !== "\r" && afterTagName !== "/") {
186
- out += html.substring(i, open + 5);
187
- i = open + 5;
188
- continue;
189
- }
190
- const openEnd = html.indexOf(">", open + 5);
191
- if (openEnd === -1) {
192
- out += html.substring(i);
193
- break;
194
- }
195
- const closeStart = html.indexOf("</span>", openEnd + 1);
196
- if (closeStart === -1) {
197
- out += html.substring(i);
198
- break;
199
- }
200
- const attrs = html.substring(open + 5, openEnd);
201
- const replacement = extract(attrs);
202
- if (replacement === null) {
203
- out += html.substring(i, open + 5);
204
- i = open + 5;
205
- continue;
206
- }
207
- out += html.substring(i, open);
208
- out += replacement;
209
- i = closeStart + 7;
210
- }
211
- return out;
212
- }
121
+ let out = "";
122
+ let i = 0;
123
+ while (i < html.length) {
124
+ const open = html.indexOf("<span", i);
125
+ if (open === -1) {
126
+ out += html.substring(i);
127
+ break;
128
+ }
129
+ const afterTagName = html[open + 5];
130
+ if (afterTagName !== ">" && afterTagName !== " " && afterTagName !== " " && afterTagName !== "\n" && afterTagName !== "\r" && afterTagName !== "/") {
131
+ out += html.substring(i, open + 5);
132
+ i = open + 5;
133
+ continue;
134
+ }
135
+ const openEnd = html.indexOf(">", open + 5);
136
+ if (openEnd === -1) {
137
+ out += html.substring(i);
138
+ break;
139
+ }
140
+ const closeStart = html.indexOf("</span>", openEnd + 1);
141
+ if (closeStart === -1) {
142
+ out += html.substring(i);
143
+ break;
144
+ }
145
+ const replacement = extract(html.substring(open + 5, openEnd));
146
+ if (replacement === null) {
147
+ out += html.substring(i, open + 5);
148
+ i = open + 5;
149
+ continue;
150
+ }
151
+ out += html.substring(i, open);
152
+ out += replacement;
153
+ i = closeStart + 7;
154
+ }
155
+ return out;
156
+ }
157
+ /**
158
+ * Extract the value of `name="…"` from an HTML attribute string, or `null`
159
+ * if absent. Uses `[^<>"]*` for the value match so a missing closing quote
160
+ * fails fast rather than backtracking across the full input.
161
+ */
213
162
  function findAttr(attrs, name) {
214
- const pattern = new RegExp(`(?:^|\\s)${name}="([^"<>]*)"`);
215
- const match = pattern.exec(attrs);
216
- return match ? match[1] : null;
217
- }
218
-
219
- // src/padding.ts
163
+ const match = new RegExp(`(?:^|\\s)${name}="([^"<>]*)"`).exec(attrs);
164
+ return match ? match[1] : null;
165
+ }
166
+ //#endregion
167
+ //#region src/padding.ts
168
+ /**
169
+ * Convert a SpacingValue to a CSS padding string like "10px 10px 10px 10px".
170
+ */
220
171
  function toPaddingString(padding) {
221
- return `${padding.top}px ${padding.right}px ${padding.bottom}px ${padding.left}px`;
222
- }
223
-
224
- // src/utils.ts
172
+ return `${padding.top}px ${padding.right}px ${padding.bottom}px ${padding.left}px`;
173
+ }
174
+ //#endregion
175
+ //#region src/utils.ts
176
+ /**
177
+ * Render the appropriate background-color attribute for an MJML element.
178
+ * Returns an empty string when no color is set, or a leading-space attribute
179
+ * fragment ready to interpolate into a tag's attribute list.
180
+ */
225
181
  function bgAttr(backgroundColor, placement) {
226
- if (!backgroundColor) {
227
- return "";
228
- }
229
- const name = placement === "native" ? "background-color" : "container-background-color";
230
- return ` ${name}="${backgroundColor}"`;
231
- }
232
-
233
- // src/visibility.ts
182
+ if (!backgroundColor) return "";
183
+ return ` ${placement === "native" ? "background-color" : "container-background-color"}="${backgroundColor}"`;
184
+ }
185
+ //#endregion
186
+ //#region src/visibility.ts
187
+ /**
188
+ * Check if a block is hidden on all viewports.
189
+ */
234
190
  function isHiddenOnAll(block) {
235
- const visibility = block.visibility;
236
- if (!visibility) {
237
- return false;
238
- }
239
- return !visibility.desktop && !visibility.tablet && !visibility.mobile;
240
- }
191
+ const visibility = block.visibility;
192
+ if (!visibility) return false;
193
+ return !visibility.desktop && !visibility.mobile;
194
+ }
195
+ /**
196
+ * Get the MJML css-class attribute string for visibility hiding.
197
+ * Returns a string like ` css-class="tpl-hide-desktop"` or empty string.
198
+ */
241
199
  function getCssClassAttr(block) {
242
- const classes = getCssClasses(block);
243
- if (classes === "") {
244
- return "";
245
- }
246
- return ` css-class="${classes}"`;
200
+ const classes = getCssClasses(block);
201
+ if (classes === "") return "";
202
+ return ` css-class="${classes}"`;
247
203
  }
204
+ /**
205
+ * Get the CSS classes for visibility hiding.
206
+ */
248
207
  function getCssClasses(block) {
249
- const visibility = block.visibility;
250
- if (!visibility) {
251
- return "";
252
- }
253
- const classes = [];
254
- if (!visibility.desktop) {
255
- classes.push("tpl-hide-desktop");
256
- }
257
- if (!visibility.tablet) {
258
- classes.push("tpl-hide-tablet");
259
- }
260
- if (!visibility.mobile) {
261
- classes.push("tpl-hide-mobile");
262
- }
263
- return classes.join(" ");
264
- }
265
-
266
- // src/renderers/title.ts
208
+ const visibility = block.visibility;
209
+ if (!visibility) return "";
210
+ const classes = [];
211
+ if (!visibility.desktop) classes.push("tpl-hide-desktop");
212
+ if (!visibility.mobile) classes.push("tpl-hide-mobile");
213
+ return classes.join(" ");
214
+ }
215
+ //#endregion
216
+ //#region src/renderers/title.ts
217
+ /**
218
+ * Render a title block to MJML markup.
219
+ */
267
220
  function renderTitle(block, context) {
268
- if (isHiddenOnAll(block)) {
269
- return "";
270
- }
271
- const padding = toPaddingString(block.styles.padding);
272
- const bgColor = bgAttr(block.styles.backgroundColor, "container");
273
- const content = unwrapParagraph(convertMergeTagsToValues(block.content));
274
- const fontSize = HEADING_LEVEL_FONT_SIZE[block.level] ?? HEADING_LEVEL_FONT_SIZE[2];
275
- const color = escapeAttr(block.color);
276
- const align = block.textAlign;
277
- const fontFamilyAttr = renderFontFamilyAttr(block.fontFamily, context);
278
- const visibilityAttr = getCssClassAttr(block);
279
- const safeLevel = HEADING_LEVEL_FONT_SIZE[block.level] ? block.level : 2;
280
- const tag = `h${safeLevel}`;
281
- return `<mj-text
221
+ if (isHiddenOnAll(block)) return "";
222
+ const padding = toPaddingString(block.styles.padding);
223
+ const bgColor = bgAttr(block.styles.backgroundColor, "container");
224
+ const content = unwrapParagraph(convertMergeTagsToValues(block.content));
225
+ const fontSize = HEADING_LEVEL_FONT_SIZE[block.level] ?? HEADING_LEVEL_FONT_SIZE[2];
226
+ const color = escapeAttr(block.color);
227
+ const align = block.textAlign;
228
+ const fontFamilyAttr = renderFontFamilyAttr$3(block.fontFamily, context);
229
+ const visibilityAttr = getCssClassAttr(block);
230
+ const tag = `h${HEADING_LEVEL_FONT_SIZE[block.level] ? block.level : 2}`;
231
+ return `<mj-text
282
232
  font-size="${fontSize}px"
283
233
  color="${color}"
284
234
  align="${align}"
@@ -286,573 +236,521 @@ function renderTitle(block, context) {
286
236
  padding="${padding}"${bgColor}${fontFamilyAttr}${visibilityAttr}
287
237
  ><${tag} style="margin:0;font-size:inherit;color:inherit;line-height:inherit">${content}</${tag}></mj-text>`;
288
238
  }
239
+ /**
240
+ * The editor stores title content as a TipTap paragraph (`<p>...</p>`),
241
+ * but the renderer wraps it in `<h${level}>`. `<p>` is invalid inside a
242
+ * heading, so strip a single outer `<p>` wrapper if present.
243
+ */
289
244
  function unwrapParagraph(html) {
290
- const match = html.match(/^\s*<p\b[^>]*>([\s\S]*)<\/p>\s*$/);
291
- if (!match) return html;
292
- if (/<\/p>\s*<p\b/i.test(match[1])) return html;
293
- return match[1];
294
- }
295
- function renderFontFamilyAttr(fontFamily, context) {
296
- if (!fontFamily) {
297
- return "";
298
- }
299
- const resolved = context.resolveFontFamily(fontFamily);
300
- return ` font-family="${resolved}"`;
301
- }
302
-
303
- // src/renderers/paragraph.ts
245
+ const match = html.match(/^\s*<p\b[^>]*>([\s\S]*)<\/p>\s*$/);
246
+ if (!match) return html;
247
+ if (/<\/p>\s*<p\b/i.test(match[1])) return html;
248
+ return match[1];
249
+ }
250
+ function renderFontFamilyAttr$3(fontFamily, context) {
251
+ if (!fontFamily) return "";
252
+ return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
253
+ }
254
+ //#endregion
255
+ //#region src/renderers/paragraph.ts
256
+ /**
257
+ * Render a paragraph block to MJML markup.
258
+ * All text formatting is inline in the HTML content (managed by TipTap).
259
+ */
304
260
  function renderParagraph(block, _context) {
305
- if (isHiddenOnAll(block)) {
306
- return "";
307
- }
308
- const stripped = block.content.replace(/<\/?p\b[^<>]*>/gi, "").trim();
309
- if (stripped === "") {
310
- return "";
311
- }
312
- const padding = toPaddingString(block.styles.padding);
313
- const bgColor = bgAttr(block.styles.backgroundColor, "container");
314
- const content = convertMergeTagsToValues(block.content);
315
- const visibilityAttr = getCssClassAttr(block);
316
- return `<mj-text
261
+ if (isHiddenOnAll(block)) return "";
262
+ if (block.content.replace(/<\/?p\b[^<>]*>/gi, "").trim() === "") return "";
263
+ const padding = toPaddingString(block.styles.padding);
264
+ const bgColor = bgAttr(block.styles.backgroundColor, "container");
265
+ const content = convertMergeTagsToValues(block.content);
266
+ return `<mj-text
317
267
  line-height="1.5"
318
- padding="${padding}"${bgColor}${visibilityAttr}
268
+ padding="${padding}"${bgColor}${getCssClassAttr(block)}
319
269
  >${content}</mj-text>`;
320
270
  }
321
-
322
- // src/renderers/image.ts
271
+ //#endregion
272
+ //#region src/renderers/image.ts
273
+ /**
274
+ * Render an image block to MJML markup.
275
+ */
323
276
  function renderImage(block, context) {
324
- if (isHiddenOnAll(block)) {
325
- return "";
326
- }
327
- if (block.src === "") {
328
- return "";
329
- }
330
- const padding = toPaddingString(block.styles.padding);
331
- const bgColor = bgAttr(block.styles.backgroundColor, "container");
332
- const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
333
- const visibilityAttr = getCssClassAttr(block);
334
- let linkAttr = "";
335
- if (block.linkUrl) {
336
- linkAttr = ` href="${escapeAttr(block.linkUrl)}"`;
337
- if (block.linkOpenInNewTab) {
338
- linkAttr += ' target="_blank" rel="noopener"';
339
- }
340
- }
341
- const src = escapeAttr(block.src);
342
- const decorative = block.decorative === true;
343
- const alt = decorative ? "" : escapeAttr(block.alt);
344
- const align = block.align;
345
- const roleAttr = decorative ? ' role="presentation"' : "";
346
- return `<mj-image
277
+ if (isHiddenOnAll(block)) return "";
278
+ if (block.src === "") return "";
279
+ const padding = toPaddingString(block.styles.padding);
280
+ const bgColor = bgAttr(block.styles.backgroundColor, "container");
281
+ const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
282
+ const visibilityAttr = getCssClassAttr(block);
283
+ let linkAttr = "";
284
+ if (block.linkUrl) {
285
+ linkAttr = ` href="${escapeAttr(block.linkUrl)}"`;
286
+ if (block.linkOpenInNewTab) linkAttr += " target=\"_blank\" rel=\"noopener\"";
287
+ }
288
+ const src = escapeAttr(block.src);
289
+ const decorative = block.decorative === true;
290
+ return `<mj-image
347
291
  src="${src}"
348
- alt="${alt}"
292
+ alt="${decorative ? "" : escapeAttr(block.alt)}"
349
293
  width="${width}"
350
- align="${align}"
351
- padding="${padding}"${bgColor}${linkAttr}${visibilityAttr}${roleAttr}
294
+ align="${block.align}"
295
+ padding="${padding}"${bgColor}${linkAttr}${visibilityAttr}${decorative ? " role=\"presentation\"" : ""}
352
296
  />`;
353
297
  }
354
-
355
- // src/renderers/button.ts
298
+ //#endregion
299
+ //#region src/renderers/button.ts
300
+ /**
301
+ * Render a button block to MJML markup.
302
+ */
356
303
  function renderButton(block, context) {
357
- if (isHiddenOnAll(block)) {
358
- return "";
359
- }
360
- const padding = toPaddingString(block.styles.padding);
361
- const bgColor = bgAttr(block.styles.backgroundColor, "container");
362
- const buttonPadding = toPaddingString(block.buttonPadding);
363
- const href = block.url === "" ? "" : escapeAttr(block.url);
364
- const hrefAttr = href === "" ? "" : ` href="${href}"`;
365
- const backgroundColor = escapeAttr(block.backgroundColor);
366
- const textColor = escapeAttr(block.textColor);
367
- const fontSize = block.fontSize;
368
- const borderRadius = block.borderRadius;
369
- const text = escapeHtml(block.text);
370
- const targetAttr = block.openInNewTab ? ' target="_blank" rel="noopener"' : "";
371
- const fontFamilyAttr = renderFontFamilyAttr2(block.fontFamily, context);
372
- const visibilityAttr = getCssClassAttr(block);
373
- return `<mj-button${hrefAttr}${targetAttr}
304
+ if (isHiddenOnAll(block)) return "";
305
+ const padding = toPaddingString(block.styles.padding);
306
+ const bgColor = bgAttr(block.styles.backgroundColor, "container");
307
+ const buttonPadding = toPaddingString(block.buttonPadding);
308
+ const href = block.url === "" ? "" : escapeAttr(block.url);
309
+ const hrefAttr = href === "" ? "" : ` href="${href}"`;
310
+ const backgroundColor = escapeAttr(block.backgroundColor);
311
+ const textColor = escapeAttr(block.textColor);
312
+ const fontSize = block.fontSize;
313
+ const borderRadius = block.borderRadius;
314
+ const text = escapeHtml(block.text);
315
+ return `<mj-button${hrefAttr}${block.openInNewTab ? " target=\"_blank\" rel=\"noopener\"" : ""}
374
316
  background-color="${backgroundColor}"
375
317
  color="${textColor}"
376
318
  font-size="${fontSize}px"
377
319
  font-weight="bold"
378
320
  border-radius="${borderRadius}px"
379
321
  inner-padding="${buttonPadding}"
380
- padding="${padding}"${bgColor}${fontFamilyAttr}${visibilityAttr}
322
+ padding="${padding}"${bgColor}${renderFontFamilyAttr$2(block.fontFamily, context)}${getCssClassAttr(block)}
381
323
  >${text}</mj-button>`;
382
324
  }
383
- function renderFontFamilyAttr2(fontFamily, context) {
384
- if (!fontFamily) {
385
- return "";
386
- }
387
- const resolved = context.resolveFontFamily(fontFamily);
388
- return ` font-family="${resolved}"`;
325
+ function renderFontFamilyAttr$2(fontFamily, context) {
326
+ if (!fontFamily) return "";
327
+ return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
389
328
  }
390
-
391
- // src/renderers/divider.ts
329
+ //#endregion
330
+ //#region src/renderers/divider.ts
331
+ /**
332
+ * Render a divider block to MJML markup.
333
+ */
392
334
  function renderDivider(block, _context) {
393
- if (isHiddenOnAll(block)) {
394
- return "";
395
- }
396
- const padding = toPaddingString(block.styles.padding);
397
- const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
398
- const width = block.width === "full" ? "100%" : block.width + "px";
399
- const thickness = block.thickness;
400
- const lineStyle = block.lineStyle;
401
- const color = escapeAttr(block.color);
402
- const visibilityAttr = getCssClassAttr(block);
403
- return `<mj-divider
404
- border-width="${thickness}px"
405
- border-style="${lineStyle}"
406
- border-color="${color}"
335
+ if (isHiddenOnAll(block)) return "";
336
+ const padding = toPaddingString(block.styles.padding);
337
+ const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
338
+ const width = block.width === "full" ? "100%" : block.width + "px";
339
+ return `<mj-divider
340
+ border-width="${block.thickness}px"
341
+ border-style="${block.lineStyle}"
342
+ border-color="${escapeAttr(block.color)}"
407
343
  width="${width}"
408
- padding="${padding}"${bgColor}${visibilityAttr}
344
+ padding="${padding}"${bgColor}${getCssClassAttr(block)}
409
345
  />`;
410
346
  }
411
-
412
- // src/renderers/spacer.ts
347
+ //#endregion
348
+ //#region src/renderers/spacer.ts
349
+ /**
350
+ * Render a spacer block to MJML markup.
351
+ *
352
+ * The canvas renders a spacer at exactly `block.height` pixels and ignores
353
+ * `block.styles.padding`. Match that here: emit `padding="0"` so the
354
+ * exported email's spacer occupies the same vertical space the user saw
355
+ * in the editor preview. Any non-zero `block.styles.padding` on a spacer
356
+ * is meaningless and silently dropped from the export.
357
+ */
413
358
  function renderSpacer(block, _context) {
414
- if (isHiddenOnAll(block)) {
415
- return "";
416
- }
417
- const height = block.height;
418
- const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
419
- const visibilityAttr = getCssClassAttr(block);
420
- return `<mj-spacer height="${height}px" padding="0"${bgColor}${visibilityAttr} />`;
421
- }
422
-
423
- // src/renderers/html.ts
359
+ if (isHiddenOnAll(block)) return "";
360
+ return `<mj-spacer height="${block.height}px" padding="0"${block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : ""}${getCssClassAttr(block)} />`;
361
+ }
362
+ //#endregion
363
+ //#region src/renderers/html.ts
364
+ /**
365
+ * Render an HTML block to MJML markup.
366
+ * No sanitization in the OSS version -- consumers are responsible for content safety.
367
+ */
424
368
  function renderHtml(block, context) {
425
- if (isHiddenOnAll(block)) {
426
- return "";
427
- }
428
- if (!context.allowHtmlBlocks) {
429
- return "";
430
- }
431
- const content = block.content;
432
- if (content === "") {
433
- return "";
434
- }
435
- const visibilityAttr = getCssClassAttr(block);
436
- return `<mj-text${visibilityAttr}>
369
+ if (isHiddenOnAll(block)) return "";
370
+ if (!context.allowHtmlBlocks) return "";
371
+ const content = block.content;
372
+ if (content === "") return "";
373
+ const visibilityAttr = getCssClassAttr(block);
374
+ return `<mj-text padding="${toPaddingString(block.styles.padding)}"${bgAttr(block.styles.backgroundColor, "container")}${visibilityAttr}>
437
375
  ${content}
438
376
  </mj-text>`;
439
377
  }
440
-
441
- // src/renderers/social.ts
378
+ //#endregion
379
+ //#region src/renderers/social.ts
380
+ /**
381
+ * Render a social icons block to MJML markup.
382
+ *
383
+ * Icons are emitted as `<img src="…/{style}/{platform}.png">` rather than
384
+ * inline SVG or base64 data URIs. Outlook desktop (Word rendering engine)
385
+ * does not support SVG and rejects base64 in `<img src>`, so hosted PNGs are
386
+ * the only format that renders across every mainstream client. The base URL
387
+ * is read from `context.socialIconsBaseUrl` (configurable via
388
+ * `RenderOptions.socialIconsBaseUrl`; default is the version-pinned unpkg
389
+ * mirror of this package).
390
+ */
442
391
  function renderSocialIcons(block, context) {
443
- if (isHiddenOnAll(block)) {
444
- return "";
445
- }
446
- const icons = block.icons;
447
- if (icons.length === 0) {
448
- return "";
449
- }
450
- const padding = toPaddingString(block.styles.padding);
451
- const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
452
- const visibilityAttr = getCssClassAttr(block);
453
- const align = block.align;
454
- const iconSize = block.iconSize;
455
- const iconStyle = block.iconStyle;
456
- const spacing = block.spacing;
457
- let iconSizePx;
458
- switch (iconSize) {
459
- case "small":
460
- iconSizePx = 24;
461
- break;
462
- case "large":
463
- iconSizePx = 48;
464
- break;
465
- default:
466
- iconSizePx = 32;
467
- break;
468
- }
469
- let borderRadius;
470
- switch (iconStyle) {
471
- case "circle":
472
- borderRadius = "50%";
473
- break;
474
- case "rounded":
475
- borderRadius = "8px";
476
- break;
477
- case "square":
478
- borderRadius = "0";
479
- break;
480
- default:
481
- borderRadius = "4px";
482
- break;
483
- }
484
- const iconCount = icons.length;
485
- const socialElements = icons.map((icon, index) => {
486
- const platform = icon.platform;
487
- const url = escapeAttr(icon.url);
488
- const iconSrc = `${context.socialIconsBaseUrl}/${iconStyle}/${platform}.png`;
489
- const rightPad = index === iconCount - 1 ? 0 : spacing;
490
- 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>`;
491
- });
492
- const socialContent = socialElements.join("\n");
493
- return `<mj-social
392
+ if (isHiddenOnAll(block)) return "";
393
+ const icons = block.icons;
394
+ if (icons.length === 0) return "";
395
+ const padding = toPaddingString(block.styles.padding);
396
+ const bgColor = block.styles.backgroundColor ? ` container-background-color="${escapeAttr(block.styles.backgroundColor)}"` : "";
397
+ const visibilityAttr = getCssClassAttr(block);
398
+ const align = block.align;
399
+ const iconSize = block.iconSize;
400
+ const iconStyle = block.iconStyle;
401
+ const spacing = block.spacing;
402
+ let iconSizePx;
403
+ switch (iconSize) {
404
+ case "small":
405
+ iconSizePx = 24;
406
+ break;
407
+ case "large":
408
+ iconSizePx = 48;
409
+ break;
410
+ default:
411
+ iconSizePx = 32;
412
+ break;
413
+ }
414
+ let borderRadius;
415
+ switch (iconStyle) {
416
+ case "circle":
417
+ borderRadius = "50%";
418
+ break;
419
+ case "rounded":
420
+ borderRadius = "8px";
421
+ break;
422
+ case "square":
423
+ borderRadius = "0";
424
+ break;
425
+ default:
426
+ borderRadius = "4px";
427
+ break;
428
+ }
429
+ const iconCount = icons.length;
430
+ return `<mj-social
494
431
  mode="horizontal"
495
432
  align="${align}"
496
433
  icon-padding="0"
497
434
  padding="${padding}"${bgColor}${visibilityAttr}
498
435
  >
499
- ${socialContent}
436
+ ${icons.map((icon, index) => {
437
+ const platform = icon.platform;
438
+ const url = escapeAttr(icon.url);
439
+ const iconSrc = `${context.socialIconsBaseUrl}/${iconStyle}/${platform}.png`;
440
+ const rightPad = index === iconCount - 1 ? 0 : spacing;
441
+ 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>`;
442
+ }).join("\n")}
500
443
  </mj-social>`;
501
444
  }
502
-
503
- // src/renderers/menu.ts
445
+ //#endregion
446
+ //#region src/renderers/menu.ts
447
+ /**
448
+ * Render a menu block to MJML markup.
449
+ * Uses mj-text with inline <a> tags separated by styled <span> separators.
450
+ */
504
451
  function renderMenu(block, context) {
505
- if (isHiddenOnAll(block)) {
506
- return "";
507
- }
508
- if (block.items.length === 0) {
509
- return "";
510
- }
511
- const padding = toPaddingString(block.styles.padding);
512
- const bgColor = bgAttr(block.styles.backgroundColor, "container");
513
- const visibilityAttr = getCssClassAttr(block);
514
- const fontFamilyAttr = renderFontFamilyAttr3(block.fontFamily, context);
515
- const align = block.textAlign;
516
- const fontSize = block.fontSize;
517
- const color = escapeAttr(block.color);
518
- const content = renderMenuItems(block);
519
- return `<mj-text
520
- font-size="${fontSize}px"
521
- color="${color}"
452
+ if (isHiddenOnAll(block)) return "";
453
+ if (block.items.length === 0) return "";
454
+ const padding = toPaddingString(block.styles.padding);
455
+ const bgColor = bgAttr(block.styles.backgroundColor, "container");
456
+ const visibilityAttr = getCssClassAttr(block);
457
+ const fontFamilyAttr = renderFontFamilyAttr$1(block.fontFamily, context);
458
+ const align = block.textAlign;
459
+ return `<mj-text
460
+ font-size="${block.fontSize}px"
461
+ color="${escapeAttr(block.color)}"
522
462
  align="${align}"
523
463
  line-height="1.5"
524
464
  padding="${padding}"${bgColor}${fontFamilyAttr}${visibilityAttr}
525
- >${content}</mj-text>`;
465
+ >${renderMenuItems(block)}</mj-text>`;
526
466
  }
527
467
  function renderMenuItems(block) {
528
- const items = block.items;
529
- const separator = escapeHtml(block.separator);
530
- const separatorColor = escapeCssValue(block.separatorColor);
531
- const spacing = block.spacing;
532
- const linkColor = block.linkColor ?? block.color;
533
- const parts = [];
534
- const itemCount = items.length;
535
- for (let index = 0; index < itemCount; index++) {
536
- parts.push(renderMenuItem(items[index], linkColor));
537
- if (index < itemCount - 1) {
538
- parts.push(
539
- `<span style="color: ${separatorColor}; padding: 0 ${spacing}px;">${separator}</span>`
540
- );
541
- }
542
- }
543
- return parts.join("");
468
+ const items = block.items;
469
+ const separator = escapeHtml(block.separator);
470
+ const separatorColor = escapeCssValue(block.separatorColor);
471
+ const spacing = block.spacing;
472
+ const linkColor = block.linkColor ?? block.color;
473
+ const parts = [];
474
+ const itemCount = items.length;
475
+ for (let index = 0; index < itemCount; index++) {
476
+ parts.push(renderMenuItem(items[index], linkColor));
477
+ if (index < itemCount - 1) parts.push(`<span style="color: ${separatorColor}; padding: 0 ${spacing}px;">${separator}</span>`);
478
+ }
479
+ return parts.join("");
544
480
  }
545
481
  function renderMenuItem(item, linkColor) {
546
- const text = escapeHtml(item.text);
547
- const url = escapeAttr(item.url);
548
- const color = escapeCssValue(item.color ?? linkColor);
549
- const target = item.openInNewTab ? ' target="_blank" rel="noopener"' : "";
550
- const styles = [`color: ${color}`, "text-decoration: none"];
551
- if (item.bold) {
552
- styles.push("font-weight: bold");
553
- }
554
- if (item.underline) {
555
- styles.push("text-decoration: underline");
556
- }
557
- const styleAttr = styles.join("; ");
558
- return `<a href="${url}" style="${styleAttr}"${target}>${text}</a>`;
559
- }
560
- function renderFontFamilyAttr3(fontFamily, context) {
561
- if (!fontFamily) {
562
- return "";
563
- }
564
- const resolved = context.resolveFontFamily(fontFamily);
565
- return ` font-family="${resolved}"`;
566
- }
567
-
568
- // src/renderers/table.ts
482
+ const text = escapeHtml(item.text);
483
+ const url = escapeAttr(item.url);
484
+ const color = escapeCssValue(item.color ?? linkColor);
485
+ const target = item.openInNewTab ? " target=\"_blank\" rel=\"noopener\"" : "";
486
+ const styles = [`color: ${color}`, "text-decoration: none"];
487
+ if (item.bold) styles.push("font-weight: bold");
488
+ if (item.underline) styles.push("text-decoration: underline");
489
+ return `<a href="${url}" style="${styles.join("; ")}"${target}>${text}</a>`;
490
+ }
491
+ function renderFontFamilyAttr$1(fontFamily, context) {
492
+ if (!fontFamily) return "";
493
+ return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
494
+ }
495
+ //#endregion
496
+ //#region src/renderers/table.ts
497
+ /**
498
+ * Render a table block to MJML markup.
499
+ * Uses mj-text wrapping an HTML <table> with styled <tr>/<td> elements.
500
+ */
569
501
  function renderTable(block, context) {
570
- if (isHiddenOnAll(block)) {
571
- return "";
572
- }
573
- if (block.rows.length === 0) {
574
- return "";
575
- }
576
- const padding = toPaddingString(block.styles.padding);
577
- const bgColor = bgAttr(block.styles.backgroundColor, "container");
578
- const visibilityAttr = getCssClassAttr(block);
579
- const fontFamilyAttr = renderFontFamilyAttr4(block.fontFamily, context);
580
- const fontSize = block.fontSize;
581
- const color = escapeAttr(block.color);
582
- const align = block.textAlign;
583
- const tableHtml = renderTableElement(block);
584
- return `<mj-text
585
- font-size="${fontSize}px"
586
- color="${color}"
587
- align="${align}"
502
+ if (isHiddenOnAll(block)) return "";
503
+ if (block.rows.length === 0) return "";
504
+ const padding = toPaddingString(block.styles.padding);
505
+ const bgColor = bgAttr(block.styles.backgroundColor, "container");
506
+ const visibilityAttr = getCssClassAttr(block);
507
+ const fontFamilyAttr = renderFontFamilyAttr(block.fontFamily, context);
508
+ return `<mj-text
509
+ font-size="${block.fontSize}px"
510
+ color="${escapeAttr(block.color)}"
511
+ align="${block.textAlign}"
588
512
  line-height="1.5"
589
513
  padding="${padding}"${bgColor}${fontFamilyAttr}${visibilityAttr}
590
- >${tableHtml}</mj-text>`;
514
+ >${renderTableElement(block)}</mj-text>`;
591
515
  }
592
516
  function renderTableElement(block) {
593
- const borderColor = escapeCssValue(block.borderColor);
594
- const borderWidth = block.borderWidth;
595
- const tableStyle = "width: 100%; border-collapse: collapse;";
596
- let rowsHtml = "";
597
- for (let index = 0; index < block.rows.length; index++) {
598
- const row = block.rows[index];
599
- const isHeader = block.hasHeaderRow && index === 0;
600
- rowsHtml += renderRow(row, block, isHeader, borderColor, borderWidth);
601
- }
602
- return `<table style="${tableStyle}">${rowsHtml}</table>`;
517
+ const borderColor = escapeCssValue(block.borderColor);
518
+ const borderWidth = block.borderWidth;
519
+ const tableStyle = "width: 100%; border-collapse: collapse;";
520
+ let rowsHtml = "";
521
+ for (let index = 0; index < block.rows.length; index++) {
522
+ const row = block.rows[index];
523
+ const isHeader = block.hasHeaderRow && index === 0;
524
+ rowsHtml += renderRow(row, block, isHeader, borderColor, borderWidth);
525
+ }
526
+ return `<table style="${tableStyle}">${rowsHtml}</table>`;
603
527
  }
604
528
  function renderRow(row, block, isHeader, borderColor, borderWidth) {
605
- let cellsHtml = "";
606
- for (const cell of row.cells) {
607
- cellsHtml += renderCell(cell, block, isHeader, borderColor, borderWidth);
608
- }
609
- return `<tr>${cellsHtml}</tr>`;
529
+ let cellsHtml = "";
530
+ for (const cell of row.cells) cellsHtml += renderCell(cell, block, isHeader, borderColor, borderWidth);
531
+ return `<tr>${cellsHtml}</tr>`;
610
532
  }
611
533
  function renderCell(cell, block, isHeader, borderColor, borderWidth) {
612
- const cellPadding = block.cellPadding;
613
- const styles = [
614
- `border: ${borderWidth}px solid ${borderColor}`,
615
- `padding: ${cellPadding}px`
616
- ];
617
- if (isHeader) {
618
- styles.push("font-weight: bold");
619
- if (block.headerBackgroundColor) {
620
- styles.push(
621
- `background-color: ${escapeCssValue(block.headerBackgroundColor)}`
622
- );
623
- }
624
- }
625
- const styleAttr = styles.join("; ");
626
- const content = convertMergeTagsToValues(cell.content);
627
- const tag = isHeader ? "th" : "td";
628
- return `<${tag} style="${styleAttr}">${content}</${tag}>`;
629
- }
630
- function renderFontFamilyAttr4(fontFamily, context) {
631
- if (!fontFamily) {
632
- return "";
633
- }
634
- const resolved = context.resolveFontFamily(fontFamily);
635
- return ` font-family="${resolved}"`;
534
+ const cellPadding = block.cellPadding;
535
+ const styles = [`border: ${borderWidth}px solid ${borderColor}`, `padding: ${cellPadding}px`];
536
+ if (isHeader) {
537
+ styles.push("font-weight: bold");
538
+ if (block.headerBackgroundColor) styles.push(`background-color: ${escapeCssValue(block.headerBackgroundColor)}`);
539
+ }
540
+ const styleAttr = styles.join("; ");
541
+ const content = convertMergeTagsToValues(cell.content);
542
+ const tag = isHeader ? "th" : "td";
543
+ return `<${tag} style="${styleAttr}">${content}</${tag}>`;
636
544
  }
637
-
638
- // src/renderers/custom.ts
545
+ function renderFontFamilyAttr(fontFamily, context) {
546
+ if (!fontFamily) return "";
547
+ return ` font-family="${context.resolveFontFamily(fontFamily)}"`;
548
+ }
549
+ //#endregion
550
+ //#region src/renderers/custom.ts
551
+ /**
552
+ * Render a custom block to MJML markup.
553
+ *
554
+ * Custom block HTML resolution order:
555
+ * 1. `context.customBlockHtml` map — populated by `renderToMjml` when the
556
+ * caller passes a `renderCustomBlock` option (typical for editor
557
+ * consumers and headless callers wiring their own resolver).
558
+ * 2. `block.renderedHtml` — populated by an external pre-render step
559
+ * (e.g., a previous render pass that mutated the block).
560
+ * 3. Empty — block omitted from output.
561
+ */
639
562
  function renderCustom(block, context) {
640
- if (isHiddenOnAll(block)) {
641
- return "";
642
- }
643
- const fromContext = context.customBlockHtml.get(block.id);
644
- const content = fromContext ?? block.renderedHtml;
645
- if (!content || content === "") {
646
- return "";
647
- }
648
- const visibilityAttr = getCssClassAttr(block);
649
- const bgColor = bgAttr(block.styles?.backgroundColor, "container");
650
- return `<mj-text${bgColor}${visibilityAttr}>
563
+ if (isHiddenOnAll(block)) return "";
564
+ const content = context.customBlockHtml.get(block.id) ?? block.renderedHtml;
565
+ if (!content || content === "") return "";
566
+ const visibilityAttr = getCssClassAttr(block);
567
+ return `<mj-text padding="${toPaddingString(block.styles.padding)}"${bgAttr(block.styles.backgroundColor, "container")}${visibilityAttr}>
651
568
  ${content}
652
569
  </mj-text>`;
653
570
  }
654
-
655
- // src/renderers/section.ts
656
- import { isSection } from "@templatical/types";
657
-
658
- // src/columns.ts
571
+ //#endregion
572
+ //#region src/columns.ts
573
+ /**
574
+ * Get width percentages for each column in a layout.
575
+ */
659
576
  function getWidthPercentages(layout) {
660
- switch (layout) {
661
- case "2":
662
- return ["50%", "50%"];
663
- case "3":
664
- return ["33.33%", "33.33%", "33.34%"];
665
- case "1-2":
666
- return ["33.33%", "66.67%"];
667
- case "2-1":
668
- return ["66.67%", "33.33%"];
669
- default:
670
- return ["100%"];
671
- }
672
- }
577
+ switch (layout) {
578
+ case "2": return ["50%", "50%"];
579
+ case "3": return [
580
+ "33.33%",
581
+ "33.33%",
582
+ "33.34%"
583
+ ];
584
+ case "1-2": return ["33.33%", "66.67%"];
585
+ case "2-1": return ["66.67%", "33.33%"];
586
+ default: return ["100%"];
587
+ }
588
+ }
589
+ /**
590
+ * Get width in pixels for each column in a layout.
591
+ */
673
592
  function getWidthPixels(layout, containerWidth) {
674
- switch (layout) {
675
- case "2":
676
- return [containerWidth * 0.5, containerWidth * 0.5];
677
- case "3":
678
- return [containerWidth / 3, containerWidth / 3, containerWidth / 3];
679
- case "1-2":
680
- return [containerWidth / 3, containerWidth * 2 / 3];
681
- case "2-1":
682
- return [containerWidth * 2 / 3, containerWidth / 3];
683
- default:
684
- return [containerWidth];
685
- }
593
+ switch (layout) {
594
+ case "2": return [containerWidth * .5, containerWidth * .5];
595
+ case "3": return [
596
+ containerWidth / 3,
597
+ containerWidth / 3,
598
+ containerWidth / 3
599
+ ];
600
+ case "1-2": return [containerWidth / 3, containerWidth * 2 / 3];
601
+ case "2-1": return [containerWidth * 2 / 3, containerWidth / 3];
602
+ default: return [containerWidth];
603
+ }
604
+ }
605
+ //#endregion
606
+ //#region src/display-condition.ts
607
+ /**
608
+ * Wrap rendered block markup in the block's liquid display-condition guards
609
+ * (`<mj-raw>{% if %}</mj-raw>` … `<mj-raw>{% endif %}</mj-raw>`), if present.
610
+ *
611
+ * Returns the input unchanged when the block has no display condition, and an
612
+ * empty string when the rendered markup is empty (a hidden block) so callers
613
+ * can keep using an `=== ""` filter to drop it.
614
+ *
615
+ * Used for BOTH top-level blocks (`index.ts`) and blocks nested inside section
616
+ * columns (`renderers/section.ts`). A condition on a nested block must emit the
617
+ * same guards as a top-level one — otherwise conditional content placed inside
618
+ * a multi-column section renders unconditionally for every recipient.
619
+ */
620
+ function wrapWithDisplayCondition(block, rendered) {
621
+ if (rendered === "") return "";
622
+ const displayCondition = block.displayCondition;
623
+ if (!displayCondition) return rendered;
624
+ return `<mj-raw>${displayCondition.before}</mj-raw>
625
+ ` + rendered + `
626
+ <mj-raw>${displayCondition.after}</mj-raw>`;
686
627
  }
687
-
688
- // src/renderers/section.ts
689
- function renderSection(block, context, renderBlock2) {
690
- if (isHiddenOnAll(block)) {
691
- return "";
692
- }
693
- const columnsLayout = block.columns;
694
- const columnWidths = getWidthPercentages(columnsLayout);
695
- const columnWidthsPx = getWidthPixels(columnsLayout, context.containerWidth);
696
- const padding = toPaddingString(block.styles.padding);
697
- const bgColor = bgAttr(block.styles.backgroundColor, "native");
698
- const visibilityAttr = getCssClassAttr(block);
699
- const children = block.children;
700
- const columnsContent = [];
701
- for (let index = 0; index < children.length; index++) {
702
- const column = children[index];
703
- const width = columnWidths[index] ?? "100%";
704
- const columnWidth = Math.floor(
705
- columnWidthsPx[index] ?? context.containerWidth
706
- );
707
- const filteredColumn = filterHtmlBlocks(
708
- column,
709
- context.allowHtmlBlocks
710
- ).filter((child) => !isSection(child));
711
- const columnContext = context.withContainerWidth(columnWidth);
712
- const columnBlocks = filteredColumn.map((child) => renderBlock2(child, columnContext)).filter((value) => value !== "").join("\n");
713
- const content = columnBlocks === "" ? "<mj-text>&nbsp;</mj-text>" : columnBlocks;
714
- columnsContent.push(`<mj-column width="${width}">
628
+ //#endregion
629
+ //#region src/renderers/section.ts
630
+ /**
631
+ * Render a section block with columns to MJML markup.
632
+ */
633
+ function renderSection(block, context, renderBlock) {
634
+ if (isHiddenOnAll(block)) return "";
635
+ const columnsLayout = block.columns;
636
+ const columnWidths = getWidthPercentages(columnsLayout);
637
+ const columnWidthsPx = getWidthPixels(columnsLayout, context.containerWidth);
638
+ const padding = toPaddingString(block.styles.padding);
639
+ const bgColor = bgAttr(block.styles.backgroundColor, "native");
640
+ const visibilityAttr = getCssClassAttr(block);
641
+ const children = block.children;
642
+ const columnsContent = [];
643
+ for (let index = 0; index < children.length; index++) {
644
+ const column = children[index];
645
+ const width = columnWidths[index] ?? "100%";
646
+ const columnWidth = Math.floor(columnWidthsPx[index] ?? context.containerWidth);
647
+ const filteredColumn = filterHtmlBlocks$1(column, context.allowHtmlBlocks).filter((child) => !isSection(child));
648
+ const columnContext = context.withContainerWidth(columnWidth);
649
+ const columnBlocks = filteredColumn.map((child) => wrapWithDisplayCondition(child, renderBlock(child, columnContext))).filter((value) => value !== "").join("\n");
650
+ const content = columnBlocks === "" ? "<mj-text>&nbsp;</mj-text>" : columnBlocks;
651
+ columnsContent.push(`<mj-column width="${width}">
715
652
  ${content}
716
653
  </mj-column>`);
717
- }
718
- const columns = columnsContent.join("\n");
719
- return `<mj-section${bgColor} padding="${padding}"${visibilityAttr}>
720
- ${columns}
654
+ }
655
+ return `<mj-section${bgColor} padding="${padding}"${visibilityAttr}>
656
+ ${columnsContent.join("\n")}
721
657
  </mj-section>`;
722
658
  }
723
- function filterHtmlBlocks(blocks, allowHtmlBlocks) {
724
- if (allowHtmlBlocks) {
725
- return blocks;
726
- }
727
- return blocks.filter((block) => block.type !== "html");
728
- }
729
-
730
- // src/renderers/video.ts
659
+ /**
660
+ * Filter out HTML blocks if they are not allowed.
661
+ */
662
+ function filterHtmlBlocks$1(blocks, allowHtmlBlocks) {
663
+ if (allowHtmlBlocks) return blocks;
664
+ return blocks.filter((block) => block.type !== "html");
665
+ }
666
+ //#endregion
667
+ //#region src/renderers/video.ts
668
+ /**
669
+ * Extract video thumbnail URL from common platforms.
670
+ * Works without server-side processing — YouTube and Vimeo thumbnails are publicly accessible.
671
+ */
731
672
  function getVideoThumbnail(url, customThumbnail) {
732
- if (customThumbnail) {
733
- return customThumbnail;
734
- }
735
- if (!url) {
736
- return null;
737
- }
738
- const youtubePatterns = [
739
- /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
740
- /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/
741
- ];
742
- for (const pattern of youtubePatterns) {
743
- const match = url.match(pattern);
744
- if (match) {
745
- return `https://img.youtube.com/vi/${match[1]}/maxresdefault.jpg`;
746
- }
747
- }
748
- const vimeoMatch = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
749
- if (vimeoMatch) {
750
- return `https://vumbnail.com/${vimeoMatch[1]}.jpg`;
751
- }
752
- return null;
753
- }
673
+ if (customThumbnail) return customThumbnail;
674
+ if (!url) return null;
675
+ 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})/]) {
676
+ const match = url.match(pattern);
677
+ if (match) return `https://img.youtube.com/vi/${match[1]}/maxresdefault.jpg`;
678
+ }
679
+ const vimeoMatch = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
680
+ if (vimeoMatch) return `https://vumbnail.com/${vimeoMatch[1]}.jpg`;
681
+ return null;
682
+ }
683
+ /**
684
+ * Render a video block to MJML markup.
685
+ * Videos in email are rendered as a linked thumbnail image pointing to the video URL.
686
+ */
754
687
  function renderVideo(block, context) {
755
- if (isHiddenOnAll(block)) {
756
- return "";
757
- }
758
- const thumbnailUrl = getVideoThumbnail(block.url, block.thumbnailUrl);
759
- if (!thumbnailUrl) {
760
- return "";
761
- }
762
- const padding = toPaddingString(block.styles.padding);
763
- const bgColor = bgAttr(block.styles.backgroundColor, "container");
764
- const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
765
- const visibilityAttr = getCssClassAttr(block);
766
- const src = escapeAttr(thumbnailUrl);
767
- const alt = escapeAttr(block.alt);
768
- const align = block.align;
769
- const href = escapeAttr(block.url);
770
- return `<mj-image
771
- src="${src}"
772
- alt="${alt}"
688
+ if (isHiddenOnAll(block)) return "";
689
+ const thumbnailUrl = getVideoThumbnail(block.url, block.thumbnailUrl);
690
+ if (!thumbnailUrl) return "";
691
+ const padding = toPaddingString(block.styles.padding);
692
+ const bgColor = bgAttr(block.styles.backgroundColor, "container");
693
+ const width = block.width === "full" ? context.containerWidth + "px" : block.width + "px";
694
+ const visibilityAttr = getCssClassAttr(block);
695
+ return `<mj-image
696
+ src="${escapeAttr(thumbnailUrl)}"
697
+ alt="${escapeAttr(block.alt)}"
773
698
  width="${width}"
774
- align="${align}"
699
+ align="${block.align}"
775
700
  padding="${padding}"
776
- href="${href}"
701
+ href="${escapeAttr(block.url)}"
777
702
  target="_blank"
778
703
  rel="noopener"${bgColor}${visibilityAttr}
779
704
  />`;
780
705
  }
781
-
782
- // src/renderers/index.ts
706
+ //#endregion
707
+ //#region src/renderers/index.ts
708
+ /**
709
+ * Render a single block to MJML markup.
710
+ * Dispatches to the appropriate block-type renderer.
711
+ */
783
712
  function renderBlock(block, context) {
784
- if (isSection2(block)) {
785
- return renderSection(block, context, renderBlock);
786
- }
787
- if (isTitle(block)) {
788
- return renderTitle(block, context);
789
- }
790
- if (isParagraph(block)) {
791
- return renderParagraph(block, context);
792
- }
793
- if (isImage(block)) {
794
- return renderImage(block, context);
795
- }
796
- if (isButton(block)) {
797
- return renderButton(block, context);
798
- }
799
- if (isDivider(block)) {
800
- return renderDivider(block, context);
801
- }
802
- if (isSpacer(block)) {
803
- return renderSpacer(block, context);
804
- }
805
- if (isHtml(block)) {
806
- return renderHtml(block, context);
807
- }
808
- if (isSocialIcons(block)) {
809
- return renderSocialIcons(block, context);
810
- }
811
- if (isMenu(block)) {
812
- return renderMenu(block, context);
813
- }
814
- if (isTable(block)) {
815
- return renderTable(block, context);
816
- }
817
- if (isVideo(block)) {
818
- return renderVideo(block, context);
819
- }
820
- if (isCustomBlock(block)) {
821
- return renderCustom(block, context);
822
- }
823
- return "";
824
- }
825
-
826
- // src/index.ts
713
+ if (isSection(block)) return renderSection(block, context, renderBlock);
714
+ if (isTitle(block)) return renderTitle(block, context);
715
+ if (isParagraph(block)) return renderParagraph(block, context);
716
+ if (isImage(block)) return renderImage(block, context);
717
+ if (isButton(block)) return renderButton(block, context);
718
+ if (isDivider(block)) return renderDivider(block, context);
719
+ if (isSpacer(block)) return renderSpacer(block, context);
720
+ if (isHtml(block)) return renderHtml(block, context);
721
+ if (isSocialIcons(block)) return renderSocialIcons(block, context);
722
+ if (isMenu(block)) return renderMenu(block, context);
723
+ if (isTable(block)) return renderTable(block, context);
724
+ if (isVideo(block)) return renderVideo(block, context);
725
+ if (isCustomBlock(block)) return renderCustom(block, context);
726
+ return "";
727
+ }
728
+ //#endregion
729
+ //#region src/index.ts
730
+ /**
731
+ * Render template content to an MJML string.
732
+ *
733
+ * The function is async because resolving custom blocks may require
734
+ * asynchronous work (e.g., the editor's liquid renderer dynamically imports
735
+ * `liquidjs`). When the content has no custom blocks or `renderCustomBlock`
736
+ * is omitted, no async work is performed but the function still resolves
737
+ * synchronously — i.e., it always returns a Promise.
738
+ */
827
739
  async function renderToMjml(content, options) {
828
- const customFonts = options?.customFonts ?? [];
829
- const defaultFallbackFont = options?.defaultFallbackFont ?? "Arial, sans-serif";
830
- const allowHtmlBlocks = options?.allowHtmlBlocks ?? true;
831
- const socialIconsBaseUrl = stripTrailingSlash(
832
- options?.socialIconsBaseUrl ?? DEFAULT_SOCIAL_ICONS_BASE_URL
833
- );
834
- const customBlockHtml = await resolveCustomBlocks(
835
- content,
836
- options?.renderCustomBlock
837
- );
838
- const renderContext = new RenderContext(
839
- content.settings.width,
840
- customFonts,
841
- defaultFallbackFont,
842
- allowHtmlBlocks,
843
- customBlockHtml,
844
- socialIconsBaseUrl
845
- );
846
- const blocks = filterHtmlBlocks2(content.blocks, allowHtmlBlocks);
847
- const fontFamily = renderContext.resolveFontFamily(
848
- content.settings.fontFamily
849
- );
850
- const backgroundColor = content.settings.backgroundColor;
851
- const bodyContent = blocks.map((block) => renderTopLevelBlock(block, renderContext)).filter((value) => value !== "").join("\n");
852
- const fontDeclarations = generateFontDeclarations(customFonts);
853
- const previewTag = generatePreviewTag(content.settings.preheaderText);
854
- const lang = escapeAttr(content.settings.locale);
855
- return `<mjml lang="${lang}">
740
+ const customFonts = options?.customFonts ?? [];
741
+ const defaultFallbackFont = options?.defaultFallbackFont ?? "Arial, sans-serif";
742
+ const allowHtmlBlocks = options?.allowHtmlBlocks ?? true;
743
+ const socialIconsBaseUrl = stripTrailingSlash(options?.socialIconsBaseUrl ?? DEFAULT_SOCIAL_ICONS_BASE_URL);
744
+ const customBlockHtml = await resolveCustomBlocks(content, options?.renderCustomBlock);
745
+ const customBlockStylesheets = collectCustomBlockStylesheets(content, options?.getCustomBlockStylesheet);
746
+ const renderContext = new RenderContext(content.settings.width, customFonts, defaultFallbackFont, allowHtmlBlocks, customBlockHtml, socialIconsBaseUrl);
747
+ const blocks = filterHtmlBlocks(content.blocks, allowHtmlBlocks);
748
+ const fontFamily = renderContext.resolveFontFamily(content.settings.fontFamily);
749
+ const backgroundColor = content.settings.backgroundColor;
750
+ const bodyContent = blocks.map((block) => renderTopLevelBlock(block, renderContext)).filter((value) => value !== "").join("\n");
751
+ const fontDeclarations = generateFontDeclarations(customFonts);
752
+ const previewTag = generatePreviewTag(content.settings.preheaderText);
753
+ return `<mjml lang="${escapeAttr(content.settings.locale)}">
856
754
  <mj-head>${previewTag}
857
755
  <mj-attributes>
858
756
  <mj-all font-family="${fontFamily}" />
@@ -866,124 +764,119 @@ async function renderToMjml(content, options) {
866
764
  @media only screen and (max-width: 480px) {
867
765
  .tpl-hide-mobile { display: none !important; mso-hide: all !important; }
868
766
  }
869
- @media only screen and (min-width: 481px) and (max-width: 768px) {
870
- .tpl-hide-tablet { display: none !important; mso-hide: all !important; }
871
- }
872
- @media only screen and (min-width: 769px) {
767
+ @media only screen and (min-width: 481px) {
873
768
  .tpl-hide-desktop { display: none !important; mso-hide: all !important; }
874
769
  }
875
- </mj-style>
770
+ </mj-style>${renderCustomBlockStylesheets(customBlockStylesheets)}
876
771
  </mj-head>
877
772
  <mj-body width="${renderContext.containerWidth}px" background-color="${backgroundColor}">
878
773
  ${bodyContent}
879
774
  </mj-body>
880
775
  </mjml>`;
881
776
  }
777
+ /**
778
+ * Render a top-level block. Sections are rendered directly,
779
+ * non-section blocks are wrapped in a default section/column.
780
+ */
882
781
  function renderTopLevelBlock(block, context) {
883
- if (isSection3(block)) {
884
- const rendered = renderBlock(block, context);
885
- return wrapWithDisplayCondition(block, rendered);
886
- }
887
- const content = renderBlock(block, context);
888
- const wrapped = wrapInSection(content);
889
- return wrapWithDisplayCondition(block, wrapped);
890
- }
891
- function wrapWithDisplayCondition(block, rendered) {
892
- if (rendered === "") {
893
- return "";
894
- }
895
- const displayCondition = block.displayCondition;
896
- if (!displayCondition) {
897
- return rendered;
898
- }
899
- return `<mj-raw>${displayCondition.before}</mj-raw>
900
- ` + rendered + `
901
- <mj-raw>${displayCondition.after}</mj-raw>`;
782
+ if (isSection(block)) return wrapWithDisplayCondition(block, renderBlock(block, context));
783
+ return wrapWithDisplayCondition(block, wrapInSection(renderBlock(block, context)));
902
784
  }
785
+ /**
786
+ * Wrap block content in a default mj-section/mj-column for non-section blocks.
787
+ */
903
788
  function wrapInSection(content) {
904
- if (content === "") {
905
- return "";
906
- }
907
- return `<mj-section>
789
+ if (content === "") return "";
790
+ return `<mj-section>
908
791
  <mj-column>
909
792
  ${content}
910
793
  </mj-column>
911
794
  </mj-section>`;
912
795
  }
913
796
  function stripTrailingSlash(url) {
914
- return url.endsWith("/") ? url.slice(0, -1) : url;
797
+ return url.endsWith("/") ? url.slice(0, -1) : url;
915
798
  }
916
799
  function generatePreviewTag(preheaderText) {
917
- if (!preheaderText) {
918
- return "";
919
- }
920
- const trimmed = preheaderText.trim();
921
- if (trimmed === "") {
922
- return "";
923
- }
924
- const escaped = escapeHtml(trimmed);
925
- return `
926
- <mj-preview>${escaped}</mj-preview>`;
800
+ if (!preheaderText) return "";
801
+ const trimmed = preheaderText.trim();
802
+ if (trimmed === "") return "";
803
+ return `\n <mj-preview>${escapeHtml(trimmed)}</mj-preview>`;
927
804
  }
928
805
  function generateFontDeclarations(customFonts) {
929
- if (customFonts.length === 0) {
930
- return "";
931
- }
932
- return customFonts.map(
933
- (font) => `
934
- <mj-font name="${escapeAttr(font.name)}" href="${escapeAttr(font.url)}" />`
935
- ).join("");
936
- }
937
- function filterHtmlBlocks2(blocks, allowHtmlBlocks) {
938
- if (allowHtmlBlocks) {
939
- return blocks;
940
- }
941
- return blocks.filter((block) => block.type !== "html");
806
+ if (customFonts.length === 0) return "";
807
+ return customFonts.map((font) => `\n <mj-font name="${escapeAttr(font.name)}" href="${escapeAttr(font.url)}" />`).join("");
942
808
  }
809
+ /**
810
+ * Filter out HTML blocks if they are not allowed.
811
+ */
812
+ function filterHtmlBlocks(blocks, allowHtmlBlocks) {
813
+ if (allowHtmlBlocks) return blocks;
814
+ return blocks.filter((block) => block.type !== "html");
815
+ }
816
+ /**
817
+ * Walk the content tree, collect every custom block, then resolve each in
818
+ * parallel via the supplied callback. Returns a map keyed by block id that
819
+ * the synchronous render pass reads from. If no callback is provided, returns
820
+ * an empty map and the sync pass falls back to `block.renderedHtml`.
821
+ *
822
+ * Per-block failures bubble up — the caller decides whether to swallow or
823
+ * rethrow. We don't replace failures with placeholders here because that's
824
+ * a policy decision (the editor swallows; a strict CLI may want to fail).
825
+ */
943
826
  async function resolveCustomBlocks(content, renderCustomBlock) {
944
- const result = /* @__PURE__ */ new Map();
945
- if (!renderCustomBlock) {
946
- return result;
947
- }
948
- const customBlocks = [];
949
- collectCustomBlocks(content.blocks, customBlocks);
950
- if (customBlocks.length === 0) {
951
- return result;
952
- }
953
- const rendered = await Promise.all(
954
- customBlocks.map((block) => renderCustomBlock(block))
955
- );
956
- for (let index = 0; index < customBlocks.length; index++) {
957
- result.set(customBlocks[index].id, rendered[index]);
958
- }
959
- return result;
827
+ const result = /* @__PURE__ */ new Map();
828
+ if (!renderCustomBlock) return result;
829
+ const customBlocks = [];
830
+ collectCustomBlocks(content.blocks, customBlocks);
831
+ if (customBlocks.length === 0) return result;
832
+ const rendered = await Promise.all(customBlocks.map((block) => renderCustomBlock(block)));
833
+ for (let index = 0; index < customBlocks.length; index++) result.set(customBlocks[index].id, rendered[index]);
834
+ return result;
960
835
  }
961
836
  function collectCustomBlocks(blocks, out) {
962
- for (const block of blocks) {
963
- if (isCustomBlock2(block)) {
964
- out.push(block);
965
- continue;
966
- }
967
- if (isSection3(block)) {
968
- for (const column of block.children) {
969
- collectCustomBlocks(column, out);
970
- }
971
- }
972
- }
973
- }
974
- export {
975
- DEFAULT_SOCIAL_ICONS_BASE_URL,
976
- RenderContext,
977
- convertMergeTagsToValues,
978
- escapeAttr,
979
- escapeHtml,
980
- getCssClassAttr,
981
- getCssClasses,
982
- getWidthPercentages,
983
- getWidthPixels,
984
- isHiddenOnAll,
985
- renderBlock,
986
- renderToMjml,
987
- toPaddingString
988
- };
837
+ for (const block of blocks) {
838
+ if (isCustomBlock(block)) {
839
+ out.push(block);
840
+ continue;
841
+ }
842
+ if (isSection(block)) for (const column of block.children) collectCustomBlocks(column, out);
843
+ }
844
+ }
845
+ /**
846
+ * Walk the content tree, find every unique `customType`, ask the consumer's
847
+ * resolver for that definition's stylesheet, and return the non-empty,
848
+ * content-deduped set in insertion order.
849
+ *
850
+ * Content-level dedupe (not just by customType) means two definitions that
851
+ * happen to ship the same stylesheet string emit it only once — cheap and
852
+ * matches the "one rule, emitted once" mental model. Whitespace-only and
853
+ * empty stylesheets are skipped.
854
+ */
855
+ function collectCustomBlockStylesheets(content, resolver) {
856
+ if (!resolver) return [];
857
+ const customBlocks = [];
858
+ collectCustomBlocks(content.blocks, customBlocks);
859
+ if (customBlocks.length === 0) return [];
860
+ const seenTypes = /* @__PURE__ */ new Set();
861
+ const seenContent = /* @__PURE__ */ new Set();
862
+ const stylesheets = [];
863
+ for (const block of customBlocks) {
864
+ if (seenTypes.has(block.customType)) continue;
865
+ seenTypes.add(block.customType);
866
+ const css = resolver(block.customType);
867
+ if (!css) continue;
868
+ const trimmed = css.trim();
869
+ if (trimmed === "" || seenContent.has(trimmed)) continue;
870
+ seenContent.add(trimmed);
871
+ stylesheets.push(trimmed);
872
+ }
873
+ return stylesheets;
874
+ }
875
+ function renderCustomBlockStylesheets(stylesheets) {
876
+ if (stylesheets.length === 0) return "";
877
+ return stylesheets.map((css) => `\n <mj-style>\n${css}\n </mj-style>`).join("");
878
+ }
879
+ //#endregion
880
+ export { DEFAULT_SOCIAL_ICONS_BASE_URL, RenderContext, convertMergeTagsToValues, escapeAttr, escapeHtml, getCssClassAttr, getCssClasses, getWidthPercentages, getWidthPixels, isHiddenOnAll, renderBlock, renderToMjml, toPaddingString };
881
+
989
882
  //# sourceMappingURL=index.js.map