@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.d.ts +109 -84
- package/dist/index.js +743 -850
- package/dist/index.js.map +1 -1
- package/package.json +4 -5
package/dist/index.js
CHANGED
|
@@ -1,284 +1,234 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
var
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
"<": "<",
|
|
60
|
+
">": ">",
|
|
61
|
+
"\"": """,
|
|
62
|
+
"'": "'"
|
|
117
63
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
"&": "&",
|
|
142
|
-
"<": "<",
|
|
143
|
-
">": ">",
|
|
144
|
-
'"': """,
|
|
145
|
-
"'": "'"
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
function renderFontFamilyAttr(fontFamily, context) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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}${
|
|
268
|
+
padding="${padding}"${bgColor}${getCssClassAttr(block)}
|
|
319
269
|
>${content}</mj-text>`;
|
|
320
270
|
}
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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}${
|
|
294
|
+
align="${block.align}"
|
|
295
|
+
padding="${padding}"${bgColor}${linkAttr}${visibilityAttr}${decorative ? " role=\"presentation\"" : ""}
|
|
352
296
|
/>`;
|
|
353
297
|
}
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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}${
|
|
322
|
+
padding="${padding}"${bgColor}${renderFontFamilyAttr$2(block.fontFamily, context)}${getCssClassAttr(block)}
|
|
381
323
|
>${text}</mj-button>`;
|
|
382
324
|
}
|
|
383
|
-
function
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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}${
|
|
344
|
+
padding="${padding}"${bgColor}${getCssClassAttr(block)}
|
|
409
345
|
/>`;
|
|
410
346
|
}
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
>${
|
|
465
|
+
>${renderMenuItems(block)}</mj-text>`;
|
|
526
466
|
}
|
|
527
467
|
function renderMenuItems(block) {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
>${
|
|
514
|
+
>${renderTableElement(block)}</mj-text>`;
|
|
591
515
|
}
|
|
592
516
|
function renderTableElement(block) {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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> </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> </mj-text>" : columnBlocks;
|
|
651
|
+
columnsContent.push(`<mj-column width="${width}">
|
|
715
652
|
${content}
|
|
716
653
|
</mj-column>`);
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
${columns}
|
|
654
|
+
}
|
|
655
|
+
return `<mj-section${bgColor} padding="${padding}"${visibilityAttr}>
|
|
656
|
+
${columnsContent.join("\n")}
|
|
721
657
|
</mj-section>`;
|
|
722
658
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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="${
|
|
701
|
+
href="${escapeAttr(block.url)}"
|
|
777
702
|
target="_blank"
|
|
778
703
|
rel="noopener"${bgColor}${visibilityAttr}
|
|
779
704
|
/>`;
|
|
780
705
|
}
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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)
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
905
|
-
|
|
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
|
-
|
|
797
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
915
798
|
}
|
|
916
799
|
function generatePreviewTag(preheaderText) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
930
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|