@samva/markup 0.18.1 → 0.19.0
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/AGENTS.md +11 -4
- package/CHANGELOG.md +36 -0
- package/README.md +89 -5
- package/dist/assets-BSpgZOSR.js +61 -0
- package/dist/assets-k7_v3IOJ.d.ts +39 -0
- package/dist/{assets-CS5Xa_mr.d.ts → brand-Bxb6Sm7y.d.ts} +34 -41
- package/dist/brand.d.ts +2 -2
- package/dist/brand.js +3 -3
- package/dist/compatibility.d.ts +15 -5
- package/dist/compatibility.js +48 -5
- package/dist/compile-C1GWLQ5C.js +490 -0
- package/dist/compile-CIeuGmP-.d.ts +98 -0
- package/dist/{compiled-template-Bc4Dq9zy.d.ts → compiled-template-CPDHvFlt.d.ts} +157 -6
- package/dist/compiler.d.ts +4 -89
- package/dist/compiler.js +8 -486
- package/dist/components-DNBCz20_.js +3267 -0
- package/dist/{css-color-DLM12Osu.js → dark-scheme-BGDCCaUq.js} +12 -1
- package/dist/{diagnostic-codes-DZKb0Xcg.js → diagnostic-codes-BCwfUgnF.js} +33 -36
- package/dist/diagnostic-model-DJtqbETd.js +36 -0
- package/dist/{diagnostics-D4vSLQrO.d.ts → diagnostics-C3TSx6SU.d.ts} +22 -2
- package/dist/diagnostics.d.ts +33 -1
- package/dist/diagnostics.js +2 -1
- package/dist/edit.d.ts +15 -4
- package/dist/edit.js +67 -4
- package/dist/email/jsx-dev-runtime.d.ts +2 -2
- package/dist/email/jsx-runtime.d.ts +1 -1
- package/dist/email.d.ts +6 -2
- package/dist/email.js +6 -2
- package/dist/index.d.ts +1 -1
- package/dist/{jsx-runtime-DL8NVF1f.d.ts → jsx-runtime-HHfrVZEf.d.ts} +1 -2
- package/dist/markdown.d.ts +69 -0
- package/dist/markdown.js +95 -0
- package/dist/parse-Bex7QFqj.js +921 -0
- package/dist/{preview-rNoJBEIY.d.ts → preview-DRLWgmfg.d.ts} +3 -3
- package/dist/profile-DFk4gs7o.js +56 -0
- package/dist/{profile-JTvgCD6X.js → project-B6D64SZS.js} +497 -234
- package/dist/render.d.ts +8 -8
- package/dist/render.js +1 -2
- package/dist/{css-B_aVKrDo.js → selectors-CKGuc9MO.js} +158 -4
- package/dist/sms.d.ts +1 -1
- package/dist/{template-DmNcoH4Q.d.ts → template-4Dse7_OC.d.ts} +1 -1
- package/dist/{theme-css-P9F5S7Ic.js → theme-css-BujDuuEA.js} +1 -2
- package/dist/whatsapp.d.ts +1 -1
- package/generated/contract.json +41 -1
- package/generated/reference.md +97 -89
- package/package.json +15 -4
- package/dist/brand-CIiEqkU7.js +0 -193
- package/dist/brand-plugin-Dj9SMpfS.d.ts +0 -33
- package/dist/components-C5850-Qg.js +0 -409
- package/dist/dark-scheme-CbCwGDWH.js +0 -12
- package/dist/html-DJUK2YJF.js +0 -483
- package/dist/render-template-DwPDm3Hq.js +0 -1226
- package/dist/selectors-CYWCoxZr.js +0 -157
- package/dist/source-locations-C8wCsiAy.d.ts +0 -22
|
@@ -0,0 +1,3267 @@
|
|
|
1
|
+
import { n as isObject } from "./guards-eV1qLbf2.js";
|
|
2
|
+
import { callFormatter, createFmt } from "./fmt.js";
|
|
3
|
+
import { a as jsx, c as rawMarkup, l as withEmailDiagnostics, n as Fragment, r as collectEmailDiagnostics, s as normalizeChildren, t as EmailNode } from "./jsx-runtime-qL19u3Fy.js";
|
|
4
|
+
import { TaggedError } from "better-result";
|
|
5
|
+
//#region src/internal/unsubscribe-link.ts
|
|
6
|
+
/**
|
|
7
|
+
* Marks the one link the serializer lets carry the brand plugin's unsubscribe
|
|
8
|
+
* placeholder as its href. Only `BrandFooter` sets it; nothing public exports
|
|
9
|
+
* it, so no author-written link can claim the exemption.
|
|
10
|
+
*/
|
|
11
|
+
const UNSUBSCRIBE_LINK = Symbol("samva.unsubscribe-link");
|
|
12
|
+
/**
|
|
13
|
+
* Marks the elements `BrandFooter` writes the brand plugin's footer markers
|
|
14
|
+
* on. The serializer emits those reserved attributes only for an element
|
|
15
|
+
* carrying it, so an author cannot claim a footer the send path would then
|
|
16
|
+
* not append.
|
|
17
|
+
*/
|
|
18
|
+
const BRAND_FOOTER_MARKER = Symbol("samva.brand-footer-marker");
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/compiled-template.ts
|
|
21
|
+
/** The version this package writes. A publication pins the version it was built with. */
|
|
22
|
+
const COMPILED_TEMPLATE_VERSION = 2;
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/email/samva-brand-plugin.ts
|
|
25
|
+
/**
|
|
26
|
+
* Samva's brand plugin, the compiler's default. A template imports its
|
|
27
|
+
* organization's brand from `samva:brand`, and Samva's send path fills
|
|
28
|
+
* `%samva:unsubscribe-url%` per delivery and turns `data-samva-no-track` into
|
|
29
|
+
* its email provider's own no-track marker.
|
|
30
|
+
*/
|
|
31
|
+
const samvaBrandPlugin = {
|
|
32
|
+
specifier: "samva:brand",
|
|
33
|
+
footerAttribute: "data-samva-footer",
|
|
34
|
+
unsubscribeRowAttribute: "data-samva-unsubscribe",
|
|
35
|
+
unsubscribeUrlPlaceholder: "%samva:unsubscribe-url%",
|
|
36
|
+
noTrackAttribute: "data-samva-no-track"
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/email/brand-plugin.ts
|
|
40
|
+
const SLUG = "[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?";
|
|
41
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
42
|
+
/** `<specifier>` or `<specifier>/<slug>`, as a regular expression source with no groups. */
|
|
43
|
+
const brandSpecifierPattern = (plugin) => `${escapeRegExp(plugin.specifier)}(?:\\/${SLUG})?`;
|
|
44
|
+
/** The brand a specifier names: `undefined` slug means the default brand; `undefined` when it names none. */
|
|
45
|
+
const parseBrandSpecifier = (specifier, options = {}) => {
|
|
46
|
+
const plugin = options.brandPlugin ?? samvaBrandPlugin;
|
|
47
|
+
const match = new RegExp(`^${escapeRegExp(plugin.specifier)}(?:\\/(${SLUG}))?$`).exec(specifier);
|
|
48
|
+
return match === null ? void 0 : { slug: match[1] };
|
|
49
|
+
};
|
|
50
|
+
/** The attributes only `BrandFooter` writes; an authored element carrying one is refused. */
|
|
51
|
+
const reservedBrandAttributes = (plugin) => /* @__PURE__ */ new Set([
|
|
52
|
+
plugin.footerAttribute,
|
|
53
|
+
plugin.unsubscribeRowAttribute,
|
|
54
|
+
plugin.noTrackAttribute
|
|
55
|
+
]);
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/email/elements.ts
|
|
58
|
+
/** Attributes every supported element accepts. `class`/`className`/`style` are handled separately. */
|
|
59
|
+
const EMAIL_GLOBAL_ATTRIBUTES = {
|
|
60
|
+
id: { type: "string" },
|
|
61
|
+
title: { type: "string" },
|
|
62
|
+
lang: { type: "string" },
|
|
63
|
+
dir: {
|
|
64
|
+
type: "string",
|
|
65
|
+
values: [
|
|
66
|
+
"ltr",
|
|
67
|
+
"rtl",
|
|
68
|
+
"auto"
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
role: { type: "string" }
|
|
72
|
+
};
|
|
73
|
+
const alignment = {
|
|
74
|
+
type: "string",
|
|
75
|
+
values: [
|
|
76
|
+
"left",
|
|
77
|
+
"center",
|
|
78
|
+
"right"
|
|
79
|
+
]
|
|
80
|
+
};
|
|
81
|
+
const verticalAlignment = {
|
|
82
|
+
type: "string",
|
|
83
|
+
values: [
|
|
84
|
+
"top",
|
|
85
|
+
"middle",
|
|
86
|
+
"bottom"
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
const flowAttributes = { align: alignment };
|
|
90
|
+
const cellAttributes = {
|
|
91
|
+
align: alignment,
|
|
92
|
+
valign: verticalAlignment,
|
|
93
|
+
width: { type: "measure" },
|
|
94
|
+
height: { type: "measure" },
|
|
95
|
+
bgcolor: { type: "string" },
|
|
96
|
+
colspan: { type: "number" },
|
|
97
|
+
rowspan: { type: "number" }
|
|
98
|
+
};
|
|
99
|
+
const block = (attributes) => ({
|
|
100
|
+
content: "flow",
|
|
101
|
+
layout: "block",
|
|
102
|
+
attributes
|
|
103
|
+
});
|
|
104
|
+
const inline = () => ({
|
|
105
|
+
content: "flow",
|
|
106
|
+
layout: "inline",
|
|
107
|
+
attributes: {}
|
|
108
|
+
});
|
|
109
|
+
/** The complete supported vocabulary. Anything outside it is a compile error with a source location. */
|
|
110
|
+
const EMAIL_ELEMENTS = {
|
|
111
|
+
html: {
|
|
112
|
+
content: "flow",
|
|
113
|
+
layout: "document",
|
|
114
|
+
attributes: {
|
|
115
|
+
xmlns: { type: "string" },
|
|
116
|
+
"xmlns:o": { type: "string" },
|
|
117
|
+
"xmlns:v": { type: "string" },
|
|
118
|
+
"xmlns:w": { type: "string" }
|
|
119
|
+
},
|
|
120
|
+
childElements: ["head", "body"]
|
|
121
|
+
},
|
|
122
|
+
head: {
|
|
123
|
+
content: "flow",
|
|
124
|
+
layout: "metadata",
|
|
125
|
+
attributes: {},
|
|
126
|
+
childElements: [
|
|
127
|
+
"title",
|
|
128
|
+
"meta",
|
|
129
|
+
"style"
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
title: {
|
|
133
|
+
content: "text",
|
|
134
|
+
layout: "metadata",
|
|
135
|
+
attributes: {}
|
|
136
|
+
},
|
|
137
|
+
style: {
|
|
138
|
+
content: "raw",
|
|
139
|
+
layout: "metadata",
|
|
140
|
+
attributes: { type: { type: "string" } }
|
|
141
|
+
},
|
|
142
|
+
meta: {
|
|
143
|
+
content: "void",
|
|
144
|
+
layout: "metadata",
|
|
145
|
+
attributes: {
|
|
146
|
+
name: { type: "string" },
|
|
147
|
+
content: { type: "string" },
|
|
148
|
+
charset: { type: "string" },
|
|
149
|
+
"http-equiv": { type: "string" }
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
body: {
|
|
153
|
+
content: "flow",
|
|
154
|
+
layout: "block",
|
|
155
|
+
attributes: { bgcolor: { type: "string" } }
|
|
156
|
+
},
|
|
157
|
+
div: block(flowAttributes),
|
|
158
|
+
section: block(flowAttributes),
|
|
159
|
+
article: block(flowAttributes),
|
|
160
|
+
header: block(flowAttributes),
|
|
161
|
+
footer: block(flowAttributes),
|
|
162
|
+
main: block(flowAttributes),
|
|
163
|
+
center: block({}),
|
|
164
|
+
p: block(flowAttributes),
|
|
165
|
+
blockquote: block(flowAttributes),
|
|
166
|
+
pre: {
|
|
167
|
+
content: "flow",
|
|
168
|
+
layout: "preformatted",
|
|
169
|
+
attributes: {}
|
|
170
|
+
},
|
|
171
|
+
h1: block(flowAttributes),
|
|
172
|
+
h2: block(flowAttributes),
|
|
173
|
+
h3: block(flowAttributes),
|
|
174
|
+
h4: block(flowAttributes),
|
|
175
|
+
h5: block(flowAttributes),
|
|
176
|
+
h6: block(flowAttributes),
|
|
177
|
+
span: inline(),
|
|
178
|
+
strong: inline(),
|
|
179
|
+
b: inline(),
|
|
180
|
+
em: inline(),
|
|
181
|
+
i: inline(),
|
|
182
|
+
u: inline(),
|
|
183
|
+
s: inline(),
|
|
184
|
+
small: inline(),
|
|
185
|
+
code: inline(),
|
|
186
|
+
a: {
|
|
187
|
+
content: "flow",
|
|
188
|
+
layout: "link",
|
|
189
|
+
attributes: {
|
|
190
|
+
href: {
|
|
191
|
+
type: "url",
|
|
192
|
+
required: true
|
|
193
|
+
},
|
|
194
|
+
target: { type: "string" },
|
|
195
|
+
rel: { type: "string" },
|
|
196
|
+
name: { type: "string" }
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
img: {
|
|
200
|
+
content: "void",
|
|
201
|
+
layout: "image",
|
|
202
|
+
attributes: {
|
|
203
|
+
src: {
|
|
204
|
+
type: "url",
|
|
205
|
+
required: true
|
|
206
|
+
},
|
|
207
|
+
alt: {
|
|
208
|
+
type: "string",
|
|
209
|
+
required: true
|
|
210
|
+
},
|
|
211
|
+
width: { type: "measure" },
|
|
212
|
+
height: { type: "measure" },
|
|
213
|
+
border: { type: "measure" },
|
|
214
|
+
align: alignment
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
table: {
|
|
218
|
+
content: "flow",
|
|
219
|
+
layout: "table",
|
|
220
|
+
attributes: {
|
|
221
|
+
width: { type: "measure" },
|
|
222
|
+
height: { type: "measure" },
|
|
223
|
+
border: { type: "measure" },
|
|
224
|
+
cellpadding: { type: "measure" },
|
|
225
|
+
cellspacing: { type: "measure" },
|
|
226
|
+
align: alignment,
|
|
227
|
+
bgcolor: { type: "string" }
|
|
228
|
+
},
|
|
229
|
+
childElements: [
|
|
230
|
+
"caption",
|
|
231
|
+
"colgroup",
|
|
232
|
+
"thead",
|
|
233
|
+
"tbody",
|
|
234
|
+
"tfoot",
|
|
235
|
+
"tr"
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
caption: {
|
|
239
|
+
content: "flow",
|
|
240
|
+
layout: "block",
|
|
241
|
+
attributes: flowAttributes
|
|
242
|
+
},
|
|
243
|
+
colgroup: {
|
|
244
|
+
content: "flow",
|
|
245
|
+
layout: "table-section",
|
|
246
|
+
attributes: {
|
|
247
|
+
span: { type: "number" },
|
|
248
|
+
width: { type: "measure" }
|
|
249
|
+
},
|
|
250
|
+
childElements: ["col"]
|
|
251
|
+
},
|
|
252
|
+
col: {
|
|
253
|
+
content: "void",
|
|
254
|
+
layout: "table-section",
|
|
255
|
+
attributes: {
|
|
256
|
+
span: { type: "number" },
|
|
257
|
+
width: { type: "measure" }
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
thead: {
|
|
261
|
+
content: "flow",
|
|
262
|
+
layout: "table-section",
|
|
263
|
+
attributes: {
|
|
264
|
+
align: alignment,
|
|
265
|
+
valign: verticalAlignment,
|
|
266
|
+
bgcolor: { type: "string" }
|
|
267
|
+
},
|
|
268
|
+
childElements: ["tr"]
|
|
269
|
+
},
|
|
270
|
+
tbody: {
|
|
271
|
+
content: "flow",
|
|
272
|
+
layout: "table-section",
|
|
273
|
+
attributes: {
|
|
274
|
+
align: alignment,
|
|
275
|
+
valign: verticalAlignment,
|
|
276
|
+
bgcolor: { type: "string" }
|
|
277
|
+
},
|
|
278
|
+
childElements: ["tr"]
|
|
279
|
+
},
|
|
280
|
+
tfoot: {
|
|
281
|
+
content: "flow",
|
|
282
|
+
layout: "table-section",
|
|
283
|
+
attributes: {
|
|
284
|
+
align: alignment,
|
|
285
|
+
valign: verticalAlignment,
|
|
286
|
+
bgcolor: { type: "string" }
|
|
287
|
+
},
|
|
288
|
+
childElements: ["tr"]
|
|
289
|
+
},
|
|
290
|
+
tr: {
|
|
291
|
+
content: "flow",
|
|
292
|
+
layout: "row",
|
|
293
|
+
attributes: {
|
|
294
|
+
align: alignment,
|
|
295
|
+
valign: verticalAlignment,
|
|
296
|
+
bgcolor: { type: "string" },
|
|
297
|
+
height: { type: "measure" }
|
|
298
|
+
},
|
|
299
|
+
childElements: ["td", "th"]
|
|
300
|
+
},
|
|
301
|
+
td: {
|
|
302
|
+
content: "flow",
|
|
303
|
+
layout: "cell",
|
|
304
|
+
attributes: cellAttributes
|
|
305
|
+
},
|
|
306
|
+
th: {
|
|
307
|
+
content: "flow",
|
|
308
|
+
layout: "cell",
|
|
309
|
+
attributes: cellAttributes
|
|
310
|
+
},
|
|
311
|
+
ul: {
|
|
312
|
+
content: "flow",
|
|
313
|
+
layout: "list",
|
|
314
|
+
attributes: {},
|
|
315
|
+
childElements: ["li"]
|
|
316
|
+
},
|
|
317
|
+
ol: {
|
|
318
|
+
content: "flow",
|
|
319
|
+
layout: "list",
|
|
320
|
+
attributes: {
|
|
321
|
+
start: { type: "number" },
|
|
322
|
+
type: { type: "string" }
|
|
323
|
+
},
|
|
324
|
+
childElements: ["li"]
|
|
325
|
+
},
|
|
326
|
+
li: {
|
|
327
|
+
content: "flow",
|
|
328
|
+
layout: "list-item",
|
|
329
|
+
attributes: {}
|
|
330
|
+
},
|
|
331
|
+
br: {
|
|
332
|
+
content: "void",
|
|
333
|
+
layout: "break",
|
|
334
|
+
attributes: {}
|
|
335
|
+
},
|
|
336
|
+
hr: {
|
|
337
|
+
content: "void",
|
|
338
|
+
layout: "rule",
|
|
339
|
+
attributes: {
|
|
340
|
+
width: { type: "measure" },
|
|
341
|
+
size: { type: "number" },
|
|
342
|
+
align: alignment
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
/** Attributes that must carry a URL; these are checked against the safe-scheme rule. */
|
|
347
|
+
const isUrlAttribute = (tag, attribute) => {
|
|
348
|
+
return EMAIL_ELEMENTS[tag]?.attributes[attribute]?.type === "url";
|
|
349
|
+
};
|
|
350
|
+
/** Look up one supported element, or `undefined` when the tag is outside the vocabulary. */
|
|
351
|
+
const emailElement = (tag) => Object.hasOwn(EMAIL_ELEMENTS, tag) ? EMAIL_ELEMENTS[tag] : void 0;
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/ir/nodes.ts
|
|
354
|
+
const isStaticScalar = (value) => value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
355
|
+
/** Structural discrimination of this package's IR, shared by compilation and target lowering. */
|
|
356
|
+
const isValueNode = (node) => {
|
|
357
|
+
if (typeof node !== "object" || node === null) return true;
|
|
358
|
+
if ("type" in node || "el" in node || "each" in node || "raw" in node || "preheader" in node) return false;
|
|
359
|
+
if ("if" in node && Array.isArray(node.then)) return false;
|
|
360
|
+
return true;
|
|
361
|
+
};
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/ir/email-presentation.ts
|
|
364
|
+
/** Visit only the deterministic email expansion, including both conditional branches once. */
|
|
365
|
+
const mapEmailPresentation = (nodes, edit) => {
|
|
366
|
+
let ordinal = 0;
|
|
367
|
+
const walk = (list) => list.map((node) => {
|
|
368
|
+
if (isValueNode(node)) return node;
|
|
369
|
+
if ("el" in node) return {
|
|
370
|
+
...edit(node, ordinal++),
|
|
371
|
+
...node.children === void 0 ? {} : { children: walk(node.children) }
|
|
372
|
+
};
|
|
373
|
+
if ("raw" in node) return edit(node, ordinal++);
|
|
374
|
+
if ("each" in node) return {
|
|
375
|
+
...node,
|
|
376
|
+
children: walk(node.children)
|
|
377
|
+
};
|
|
378
|
+
if ("if" in node) return {
|
|
379
|
+
...node,
|
|
380
|
+
then: walk(node.then),
|
|
381
|
+
...node.else === void 0 ? {} : { else: walk(node.else) }
|
|
382
|
+
};
|
|
383
|
+
return node;
|
|
384
|
+
});
|
|
385
|
+
return walk(nodes);
|
|
386
|
+
};
|
|
387
|
+
/** Save changed presentation fields, never tags, text children or a second body tree. */
|
|
388
|
+
const emailPresentationOverrides = (before, styled) => {
|
|
389
|
+
const originals = [];
|
|
390
|
+
mapEmailPresentation([before], (node) => {
|
|
391
|
+
originals.push(node);
|
|
392
|
+
return node;
|
|
393
|
+
});
|
|
394
|
+
const overrides = {};
|
|
395
|
+
mapEmailPresentation([styled], (node, ordinal) => {
|
|
396
|
+
const original = originals[ordinal];
|
|
397
|
+
if (typeof node !== "object" || node === null || typeof original !== "object" || original === null) return node;
|
|
398
|
+
if ("el" in node && "el" in original) {
|
|
399
|
+
const attrs = JSON.stringify(node.attrs) === JSON.stringify(original.attrs) ? void 0 : node.attrs;
|
|
400
|
+
const style = JSON.stringify(node.style) === JSON.stringify(original.style) ? void 0 : node.style ?? "";
|
|
401
|
+
if (attrs !== void 0 || style !== void 0) overrides[ordinal] = {
|
|
402
|
+
...attrs === void 0 ? {} : { attrs },
|
|
403
|
+
...style === void 0 ? {} : { style }
|
|
404
|
+
};
|
|
405
|
+
} else if ("raw" in node && "raw" in original && JSON.stringify(node.raw) !== JSON.stringify(original.raw)) overrides[ordinal] = { raw: node.raw };
|
|
406
|
+
return node;
|
|
407
|
+
});
|
|
408
|
+
return overrides;
|
|
409
|
+
};
|
|
410
|
+
/** Apply the resolved cascade and append head-only CSS after all ordinal-based overrides. */
|
|
411
|
+
const applyEmailPresentation = (nodes, presentation) => {
|
|
412
|
+
const painted = mapEmailPresentation(nodes, (node, ordinal) => ({
|
|
413
|
+
...node,
|
|
414
|
+
...presentation.nodes[ordinal]
|
|
415
|
+
}));
|
|
416
|
+
if (presentation.headCss === "" && presentation.fontFaceCss === "") return painted;
|
|
417
|
+
let done = false;
|
|
418
|
+
const additions = [...presentation.headCss === "" ? [] : [{
|
|
419
|
+
el: "style",
|
|
420
|
+
children: [presentation.headCss]
|
|
421
|
+
}], ...presentation.fontFaceCss === "" ? [] : [
|
|
422
|
+
{ raw: ["<!--[if !mso]><!-->"] },
|
|
423
|
+
{
|
|
424
|
+
el: "style",
|
|
425
|
+
children: [presentation.fontFaceCss]
|
|
426
|
+
},
|
|
427
|
+
{ raw: ["<!--<![endif]-->"] }
|
|
428
|
+
]];
|
|
429
|
+
const walk = (list) => list.map((node) => {
|
|
430
|
+
if (done || isValueNode(node)) return node;
|
|
431
|
+
if ("el" in node) {
|
|
432
|
+
if (node.el === "head") {
|
|
433
|
+
done = true;
|
|
434
|
+
return {
|
|
435
|
+
...node,
|
|
436
|
+
children: [...node.children ?? [], ...additions]
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
...node,
|
|
441
|
+
...node.children === void 0 ? {} : { children: walk(node.children) }
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if ("each" in node) return {
|
|
445
|
+
...node,
|
|
446
|
+
children: walk(node.children)
|
|
447
|
+
};
|
|
448
|
+
if ("if" in node) return {
|
|
449
|
+
...node,
|
|
450
|
+
then: walk(node.then),
|
|
451
|
+
...node.else === void 0 ? {} : { else: walk(node.else) }
|
|
452
|
+
};
|
|
453
|
+
return node;
|
|
454
|
+
});
|
|
455
|
+
return walk(painted);
|
|
456
|
+
};
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/ir/degradations.ts
|
|
459
|
+
/** Content fidelity declarations; client-specific presentation is checked against the HTML. */
|
|
460
|
+
const COMPONENT_DEGRADATIONS = {
|
|
461
|
+
Email: {
|
|
462
|
+
kind: "same",
|
|
463
|
+
note: "Recipients get the full message with the theme's colors and font."
|
|
464
|
+
},
|
|
465
|
+
Section: {
|
|
466
|
+
kind: "same",
|
|
467
|
+
note: "Recipients see the section and everything in it."
|
|
468
|
+
},
|
|
469
|
+
Columns: {
|
|
470
|
+
kind: "same",
|
|
471
|
+
note: "Recipients see every cell of every row."
|
|
472
|
+
},
|
|
473
|
+
Column: {
|
|
474
|
+
kind: "same",
|
|
475
|
+
note: "Recipients see the cell and everything in it."
|
|
476
|
+
},
|
|
477
|
+
Button: {
|
|
478
|
+
kind: "same",
|
|
479
|
+
note: "Recipients see the button's label and can follow its link."
|
|
480
|
+
},
|
|
481
|
+
Spacer: {
|
|
482
|
+
kind: "same",
|
|
483
|
+
note: "Recipients see the space between blocks."
|
|
484
|
+
},
|
|
485
|
+
Divider: {
|
|
486
|
+
kind: "same",
|
|
487
|
+
note: "Recipients see the horizontal rule."
|
|
488
|
+
},
|
|
489
|
+
Image: {
|
|
490
|
+
kind: "same",
|
|
491
|
+
note: "Recipients get the image and its alternative text."
|
|
492
|
+
},
|
|
493
|
+
Link: {
|
|
494
|
+
kind: "same",
|
|
495
|
+
note: "Recipients can follow the link."
|
|
496
|
+
},
|
|
497
|
+
BrandLogo: {
|
|
498
|
+
kind: "same",
|
|
499
|
+
note: "Recipients get the brand logo, with the dark version where their client supports it."
|
|
500
|
+
},
|
|
501
|
+
BrandFooter: {
|
|
502
|
+
kind: "same",
|
|
503
|
+
note: "Recipients see the brand's contact details and links, including the unsubscribe link when supplied."
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
/** The content fidelity declaration for one supported component. */
|
|
507
|
+
const componentDegradation = (name) => COMPONENT_DEGRADATIONS[name];
|
|
508
|
+
//#endregion
|
|
509
|
+
//#region src/ir/validate.ts
|
|
510
|
+
/** Bound the static artifact as well as the renderer's dynamic iteration and expression limits. */
|
|
511
|
+
const validateEmailIr = (body, presentation, reject) => {
|
|
512
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
513
|
+
let visited = 0;
|
|
514
|
+
const bound = (value, depth) => {
|
|
515
|
+
if (depth > 512 || ++visited > 1e5) reject("The compiled email exceeds its depth or size limit.", "render-limit");
|
|
516
|
+
if (typeof value === "string" && /[]/.test(value)) reject("Compiled strings cannot contain internal token delimiters.");
|
|
517
|
+
if (typeof value === "number" && !Number.isFinite(value)) reject("Compiled numbers must be finite.");
|
|
518
|
+
if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") reject("A compiled email contains only JSON data.");
|
|
519
|
+
if (Array.isArray(value)) {
|
|
520
|
+
if (value.length > 1e4) reject("A compiled list contains at most 10000 entries.", "render-limit");
|
|
521
|
+
for (const item of value) bound(item, depth + 1);
|
|
522
|
+
} else if (record(value)) for (const item of Object.values(value)) bound(item, depth + 1);
|
|
523
|
+
};
|
|
524
|
+
bound(body, 0);
|
|
525
|
+
bound(presentation, 0);
|
|
526
|
+
const list = (value) => {
|
|
527
|
+
if (!Array.isArray(value)) reject("Node children must be an array.");
|
|
528
|
+
for (const child of value) node(child);
|
|
529
|
+
};
|
|
530
|
+
const node = (value) => {
|
|
531
|
+
if (value === void 0) reject("A compiled node cannot be undefined.");
|
|
532
|
+
if (typeof value !== "object" || value === null) return;
|
|
533
|
+
if (!record(value)) reject("An array cannot be rendered as a node.");
|
|
534
|
+
if (value.src !== void 0 && (!Array.isArray(value.src) || value.src.length < 2 || value.src.length > 3 || !value.src.every((part) => Number.isInteger(part) && Number(part) >= 0))) reject("A source reference is a line, column and optional file index.");
|
|
535
|
+
if ("type" in value) {
|
|
536
|
+
if (value.type === "component") {
|
|
537
|
+
if (typeof value.name !== "string" || !Object.hasOwn(COMPONENT_DEGRADATIONS, value.name)) reject("Unknown semantic component.");
|
|
538
|
+
if (value.props !== void 0 && !record(value.props)) reject("Component props must be an object.");
|
|
539
|
+
if (value.emailColumn !== void 0 && (value.name !== "Column" || !record(value.emailColumn) || typeof value.emailColumn.stack !== "boolean")) reject("Column email layout requires its compile-resolved stacking flag.");
|
|
540
|
+
if ((value.name === "BrandLogo" || value.name === "BrandFooter") && (!record(value.brand) || typeof value.brand.slug !== "string")) reject("Brand components require resolved brand data.");
|
|
541
|
+
} else if (value.type === "block" || value.type === "inline") {
|
|
542
|
+
if (typeof value.tag !== "string" || emailElement(value.tag) === void 0) reject("Unknown semantic intrinsic.");
|
|
543
|
+
if (value.attrs !== void 0 && !record(value.attrs)) reject("Intrinsic attributes must be an object.");
|
|
544
|
+
} else reject("Unknown semantic node kind.");
|
|
545
|
+
if (value.children !== void 0) list(value.children);
|
|
546
|
+
} else if ("each" in value) {
|
|
547
|
+
if (typeof value.as !== "string" || value.as === "") reject("A loop requires a variable name.");
|
|
548
|
+
list(value.children);
|
|
549
|
+
} else if ("if" in value && Array.isArray(value.then)) {
|
|
550
|
+
list(value.then);
|
|
551
|
+
if (value.else !== void 0) list(value.else);
|
|
552
|
+
} else if ("el" in value) {
|
|
553
|
+
if (value.attrs !== void 0 && !record(value.attrs)) reject("Element attributes must be an object.");
|
|
554
|
+
if (value.children !== void 0) list(value.children);
|
|
555
|
+
} else if ("raw" in value && !Array.isArray(value.raw)) reject("Compatibility markup requires a list of parts.");
|
|
556
|
+
};
|
|
557
|
+
node(body);
|
|
558
|
+
if (presentation === void 0) return;
|
|
559
|
+
if (!record(presentation) || !record(presentation.nodes) || typeof presentation.headCss !== "string" || typeof presentation.fontFaceCss !== "string" || !record(presentation.brandPlugin)) reject("Invalid email presentation plan.");
|
|
560
|
+
for (const [ordinal, entry] of Object.entries(presentation.nodes)) {
|
|
561
|
+
if (!/^(0|[1-9]\d*)$/.test(ordinal) || !record(entry)) reject("Invalid email presentation ordinal.");
|
|
562
|
+
if (Object.keys(entry).some((key) => ![
|
|
563
|
+
"attrs",
|
|
564
|
+
"style",
|
|
565
|
+
"raw"
|
|
566
|
+
].includes(key))) reject("A presentation override cannot contain document structure.");
|
|
567
|
+
if (entry.attrs !== void 0 && !record(entry.attrs)) reject("Presentation attributes must be an object.");
|
|
568
|
+
if (entry.raw !== void 0 && !Array.isArray(entry.raw)) reject("Presentation compatibility markup requires a list of parts.");
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/email/brand.ts
|
|
573
|
+
/** Every class the brand components use, compiled for any project that imports them. */
|
|
574
|
+
const BRAND_COMPONENT_CLASSES = [
|
|
575
|
+
"dark:hidden!",
|
|
576
|
+
"hidden",
|
|
577
|
+
"dark:block!",
|
|
578
|
+
"text-xs",
|
|
579
|
+
"text-muted-foreground",
|
|
580
|
+
"text-center",
|
|
581
|
+
"font-semibold",
|
|
582
|
+
"underline"
|
|
583
|
+
];
|
|
584
|
+
const addressLines = (address) => [
|
|
585
|
+
address.street1,
|
|
586
|
+
address.street2,
|
|
587
|
+
[
|
|
588
|
+
address.city,
|
|
589
|
+
address.region,
|
|
590
|
+
address.postalCode
|
|
591
|
+
].filter((part) => part.trim() !== "").join(", "),
|
|
592
|
+
address.country
|
|
593
|
+
].filter((line) => line !== void 0 && line.trim() !== "");
|
|
594
|
+
const classes = (...names) => {
|
|
595
|
+
const joined = names.filter((name) => name !== void 0 && name !== "").join(" ");
|
|
596
|
+
return joined === "" ? void 0 : joined;
|
|
597
|
+
};
|
|
598
|
+
const line = (children, last) => jsx("p", {
|
|
599
|
+
style: { margin: last ? "0" : "0 0 8px 0" },
|
|
600
|
+
children
|
|
601
|
+
});
|
|
602
|
+
/** The alignment a footer `className` asks for; the footer centers when it names none. */
|
|
603
|
+
const footerAlignment = (className) => /(?:^|\s)text-(left|center|right)(?:\s|$)/.exec(className ?? "")?.[1] ?? "center";
|
|
604
|
+
/** Bind `BrandLogo` and `BrandFooter` to one brand, marked as the plugin names. */
|
|
605
|
+
const brandComponents = (brand, plugin = samvaBrandPlugin) => {
|
|
606
|
+
const assets = brand.assets ?? {};
|
|
607
|
+
const footer = brand.footer ?? {};
|
|
608
|
+
const BrandLogo = ({ width = 120, alt, align, className }) => {
|
|
609
|
+
if (assets.logoUrl === void 0) return null;
|
|
610
|
+
const label = alt ?? footer.companyName ?? "";
|
|
611
|
+
const image = (src, imageClass, imageAlt) => jsx("img", {
|
|
612
|
+
src,
|
|
613
|
+
alt: imageAlt,
|
|
614
|
+
width,
|
|
615
|
+
border: 0,
|
|
616
|
+
...align === void 0 ? {} : { align },
|
|
617
|
+
className: classes(imageClass, className),
|
|
618
|
+
style: {
|
|
619
|
+
display: "block",
|
|
620
|
+
outline: "none",
|
|
621
|
+
border: "none",
|
|
622
|
+
height: "auto"
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
if (assets.logoDarkUrl === void 0) return image(assets.logoUrl, void 0, label);
|
|
626
|
+
return jsx(Fragment, { children: [image(assets.logoUrl, "dark:hidden!", label), jsx("div", {
|
|
627
|
+
className: "hidden dark:block!",
|
|
628
|
+
style: {
|
|
629
|
+
msoHide: "all",
|
|
630
|
+
maxHeight: 0,
|
|
631
|
+
overflow: "hidden"
|
|
632
|
+
},
|
|
633
|
+
children: image(assets.logoDarkUrl, void 0, "")
|
|
634
|
+
})] });
|
|
635
|
+
};
|
|
636
|
+
const BrandFooter = ({ unsubscribeLabel = "Unsubscribe", className }) => {
|
|
637
|
+
const items = [];
|
|
638
|
+
const separator = () => items.length === 0 ? [] : [" · "];
|
|
639
|
+
if (footer.companyName !== void 0 && footer.companyName.trim() !== "") items.push(jsx("span", {
|
|
640
|
+
className: "font-semibold",
|
|
641
|
+
children: footer.companyName
|
|
642
|
+
}));
|
|
643
|
+
for (const link of footer.socialLinks ?? []) items.push(...separator(), jsx("a", {
|
|
644
|
+
href: link.url,
|
|
645
|
+
className: "text-muted-foreground underline",
|
|
646
|
+
children: link.label
|
|
647
|
+
}));
|
|
648
|
+
items.push(jsx("span", {
|
|
649
|
+
[plugin.unsubscribeRowAttribute]: "",
|
|
650
|
+
[BRAND_FOOTER_MARKER]: true,
|
|
651
|
+
children: [...items.length === 0 ? [] : [rawMarkup(" · ", "\n")], unsubscribeLink(unsubscribeLabel, plugin)]
|
|
652
|
+
}));
|
|
653
|
+
const address = footer.address === void 0 ? [] : addressLines(footer.address);
|
|
654
|
+
const alignment = footerAlignment(className);
|
|
655
|
+
return jsx("table", {
|
|
656
|
+
role: "presentation",
|
|
657
|
+
width: "100%",
|
|
658
|
+
border: 0,
|
|
659
|
+
cellpadding: 0,
|
|
660
|
+
cellspacing: 0,
|
|
661
|
+
[plugin.footerAttribute]: "",
|
|
662
|
+
[BRAND_FOOTER_MARKER]: true,
|
|
663
|
+
children: jsx("tbody", { children: jsx("tr", { children: jsx("td", {
|
|
664
|
+
align: alignment,
|
|
665
|
+
className: classes("text-xs text-muted-foreground", alignment === "center" ? "text-center" : void 0, className),
|
|
666
|
+
style: { padding: "32px 16px" },
|
|
667
|
+
children: [line(items, address.length === 0), ...address.length === 0 ? [] : [line([address.join(", ")], true)]]
|
|
668
|
+
}) }) })
|
|
669
|
+
});
|
|
670
|
+
};
|
|
671
|
+
return {
|
|
672
|
+
BrandLogo,
|
|
673
|
+
BrandFooter
|
|
674
|
+
};
|
|
675
|
+
};
|
|
676
|
+
/**
|
|
677
|
+
* The unsubscribe link. Its href is the placeholder, which no author-written
|
|
678
|
+
* link may carry: the serializer accepts it only on this node, and writes the
|
|
679
|
+
* plugin's no-track marker on it so click tracking never rewrites it.
|
|
680
|
+
*/
|
|
681
|
+
const unsubscribeLink = (label, plugin) => jsx("a", {
|
|
682
|
+
href: plugin.unsubscribeUrlPlaceholder,
|
|
683
|
+
className: "text-muted-foreground underline",
|
|
684
|
+
[UNSUBSCRIBE_LINK]: true,
|
|
685
|
+
children: label
|
|
686
|
+
});
|
|
687
|
+
/** Read the plugin's footer markers from rendered HTML. */
|
|
688
|
+
const renderedFooterMarkers = (html, plugin = samvaBrandPlugin) => ({
|
|
689
|
+
footer: html.includes(`${plugin.footerAttribute}=""`),
|
|
690
|
+
unsubscribeLink: html.includes(`href="${escapeAttribute(plugin.unsubscribeUrlPlaceholder)}"`)
|
|
691
|
+
});
|
|
692
|
+
/**
|
|
693
|
+
* Drop the unsubscribe row from a rendered message, for a send that resolved
|
|
694
|
+
* no unsubscribe group: there is no link to put there, so the row goes.
|
|
695
|
+
*/
|
|
696
|
+
const withoutUnsubscribeRow = (rendered, plugin = samvaBrandPlugin) => ({
|
|
697
|
+
html: rendered.html?.replace(new RegExp(`<(span|tr) ${plugin.unsubscribeRowAttribute}=""[^>]*>[\\s\\S]*?</\\1>`, "g"), ""),
|
|
698
|
+
text: rendered.text?.split("\n").filter((textLine) => !textLine.includes(plugin.unsubscribeUrlPlaceholder)).join("\n")
|
|
699
|
+
});
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/email/primitive-css.ts
|
|
702
|
+
/** The classes the primitives put on the elements they generate. */
|
|
703
|
+
const PRIMITIVE_CLASSES = {
|
|
704
|
+
email: "sml-email",
|
|
705
|
+
button: "sml-button",
|
|
706
|
+
link: "sml-link",
|
|
707
|
+
divider: "sml-divider",
|
|
708
|
+
stack: "sml-stack"
|
|
709
|
+
};
|
|
710
|
+
const PRIMITIVE_CSS = `@layer components {
|
|
711
|
+
.${PRIMITIVE_CLASSES.email} {
|
|
712
|
+
color: var(--color-foreground);
|
|
713
|
+
font-family: var(--font-sans);
|
|
714
|
+
padding-left: 16px;
|
|
715
|
+
padding-right: 16px;
|
|
716
|
+
}
|
|
717
|
+
.${PRIMITIVE_CLASSES.button} {
|
|
718
|
+
background-color: var(--color-primary);
|
|
719
|
+
color: var(--color-primary-foreground);
|
|
720
|
+
font-family: var(--font-sans);
|
|
721
|
+
border-radius: var(--radius);
|
|
722
|
+
}
|
|
723
|
+
.${PRIMITIVE_CLASSES.link} {
|
|
724
|
+
color: var(--color-primary);
|
|
725
|
+
text-decoration: underline;
|
|
726
|
+
}
|
|
727
|
+
.${PRIMITIVE_CLASSES.divider} {
|
|
728
|
+
border-top: 1px solid var(--color-border);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
@media only screen and (max-width: 599px) {
|
|
732
|
+
.${PRIMITIVE_CLASSES.stack} {
|
|
733
|
+
display: block;
|
|
734
|
+
width: 100%;
|
|
735
|
+
max-width: 100%;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
`;
|
|
739
|
+
//#endregion
|
|
740
|
+
//#region src/ir/tokens.ts
|
|
741
|
+
const OPEN = "";
|
|
742
|
+
const CLOSE = "";
|
|
743
|
+
const TOKEN = /(\d+)/g;
|
|
744
|
+
/** Values and node lists a component call reads at render time, addressable from inside a string. */
|
|
745
|
+
var Tokens = class {
|
|
746
|
+
entries = [];
|
|
747
|
+
value(value) {
|
|
748
|
+
if (typeof value === "string") return value;
|
|
749
|
+
this.entries.push({ value });
|
|
750
|
+
return `${OPEN}${this.entries.length - 1}${CLOSE}`;
|
|
751
|
+
}
|
|
752
|
+
nodes(nodes) {
|
|
753
|
+
this.entries.push({ nodes });
|
|
754
|
+
return `${OPEN}${this.entries.length - 1}${CLOSE}`;
|
|
755
|
+
}
|
|
756
|
+
/** A string with tokens in it, as static text and the entries it names. */
|
|
757
|
+
parse(input) {
|
|
758
|
+
const parts = [];
|
|
759
|
+
let last = 0;
|
|
760
|
+
for (const match of input.matchAll(TOKEN)) {
|
|
761
|
+
if (match.index > last) parts.push(input.slice(last, match.index));
|
|
762
|
+
const entry = this.entries[Number(match[1])];
|
|
763
|
+
if (entry !== void 0) parts.push(entry);
|
|
764
|
+
last = match.index + match[0].length;
|
|
765
|
+
}
|
|
766
|
+
if (last < input.length) parts.push(input.slice(last));
|
|
767
|
+
return parts;
|
|
768
|
+
}
|
|
769
|
+
/** The value a string with tokens stands for: itself, one entry's value, or their concatenation. */
|
|
770
|
+
toValue(input) {
|
|
771
|
+
const parts = this.parse(input);
|
|
772
|
+
if (parts.length === 0) return "";
|
|
773
|
+
const values = [];
|
|
774
|
+
for (const part of parts) if (typeof part === "string") values.push(part);
|
|
775
|
+
else if ("value" in part) values.push(part.value);
|
|
776
|
+
else return void 0;
|
|
777
|
+
return values.length === 1 ? values[0] : { concat: values };
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
/** A prop value for a primitive: a literal stays itself and anything else becomes a token. */
|
|
781
|
+
const propFor = (tokens, value) => isStaticScalar(value) ? value : tokens.value(value);
|
|
782
|
+
const SOURCE = (source) => source === void 0 ? {} : { src: source };
|
|
783
|
+
/** Split compatibility markup into parts, deciding by position whether a token is in a tag. */
|
|
784
|
+
const rawParts = (tokens, html) => {
|
|
785
|
+
const parts = [];
|
|
786
|
+
let inTag = false;
|
|
787
|
+
let quote;
|
|
788
|
+
let buffer = "";
|
|
789
|
+
const flush = () => {
|
|
790
|
+
if (buffer !== "") parts.push(buffer);
|
|
791
|
+
buffer = "";
|
|
792
|
+
};
|
|
793
|
+
for (let index = 0; index < html.length; index++) {
|
|
794
|
+
const character = html[index];
|
|
795
|
+
if (character === OPEN) {
|
|
796
|
+
const end = html.indexOf(CLOSE, index);
|
|
797
|
+
const entry = tokens.parse(html.slice(index, end + 1))[0];
|
|
798
|
+
if (end > index && entry !== void 0 && typeof entry !== "string" && "value" in entry) {
|
|
799
|
+
flush();
|
|
800
|
+
parts.push(inTag ? { attr: entry.value } : { text: entry.value });
|
|
801
|
+
index = end;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (!inTag && character === "<") inTag = true;
|
|
806
|
+
else if (inTag && quote === void 0 && (character === "\"" || character === "'")) quote = character;
|
|
807
|
+
else if (inTag && quote === character) quote = void 0;
|
|
808
|
+
else if (inTag && quote === void 0 && character === ">") inTag = false;
|
|
809
|
+
buffer += character;
|
|
810
|
+
}
|
|
811
|
+
flush();
|
|
812
|
+
return parts;
|
|
813
|
+
};
|
|
814
|
+
const styleList = (tokens, style) => {
|
|
815
|
+
if (!isObject(style)) return void 0;
|
|
816
|
+
const entries = [];
|
|
817
|
+
for (const [property, value] of Object.entries(style)) {
|
|
818
|
+
if (value === void 0 || value === null) continue;
|
|
819
|
+
if (typeof value === "number") entries.push([property, value]);
|
|
820
|
+
else if (typeof value === "string") {
|
|
821
|
+
const resolved = tokens.toValue(value);
|
|
822
|
+
if (resolved !== void 0) entries.push([property, resolved]);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return entries;
|
|
826
|
+
};
|
|
827
|
+
/** Turn the tree a primitive returned back into IR, restoring each token to what it stood for. */
|
|
828
|
+
const emailNodeToIr = (node, tokens, source) => {
|
|
829
|
+
if (node === null) return [];
|
|
830
|
+
switch (node.type) {
|
|
831
|
+
case "Text": {
|
|
832
|
+
const output = [];
|
|
833
|
+
for (const part of tokens.parse(node.value)) if (typeof part === "string") output.push(part);
|
|
834
|
+
else if ("value" in part) output.push(part.value);
|
|
835
|
+
else output.push(...part.nodes);
|
|
836
|
+
return output;
|
|
837
|
+
}
|
|
838
|
+
case "Fragment": return node.children.flatMap((childNode) => emailNodeToIr(childNode, tokens, source));
|
|
839
|
+
case "Raw": return [{
|
|
840
|
+
raw: rawParts(tokens, node.html),
|
|
841
|
+
...node.text === "" ? {} : { text: node.text },
|
|
842
|
+
...SOURCE(source)
|
|
843
|
+
}];
|
|
844
|
+
case "Element": {
|
|
845
|
+
const attrs = {};
|
|
846
|
+
let style;
|
|
847
|
+
for (const [key, value] of Object.entries(node.props)) {
|
|
848
|
+
if (key === "key" || value === void 0 || value === null || value === false) continue;
|
|
849
|
+
if (key === "style") {
|
|
850
|
+
style = styleList(tokens, value);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
const name = key === "className" ? "class" : key;
|
|
854
|
+
if (typeof value === "string") attrs[name] = tokens.toValue(value) ?? value;
|
|
855
|
+
else if (typeof value === "number" || value === true) attrs[name] = value;
|
|
856
|
+
}
|
|
857
|
+
const reserved = node.props[BRAND_FOOTER_MARKER] === true;
|
|
858
|
+
return [{
|
|
859
|
+
el: node.tag,
|
|
860
|
+
...Object.keys(attrs).length === 0 ? {} : { attrs },
|
|
861
|
+
...style === void 0 || Array.isArray(style) && style.length === 0 ? {} : { style },
|
|
862
|
+
...node.children.length === 0 ? {} : { children: node.children.flatMap((childNode) => emailNodeToIr(childNode, tokens, source)) },
|
|
863
|
+
...reserved ? { reserved: true } : {},
|
|
864
|
+
...SOURCE(source)
|
|
865
|
+
}];
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/lower/email.ts
|
|
871
|
+
const prop = (tokens, value) => {
|
|
872
|
+
if (typeof value === "object" && value !== null && "object" in value) return Object.fromEntries(Object.entries(value.object).map(([key, field]) => [key, prop(tokens, field)]));
|
|
873
|
+
return propFor(tokens, value);
|
|
874
|
+
};
|
|
875
|
+
/** Pass tokens through the real primitives, preserving their string-child/VML behavior. */
|
|
876
|
+
const childrenProp = (tokens, nodes) => {
|
|
877
|
+
const only = nodes.length === 1 ? nodes[0] : void 0;
|
|
878
|
+
if (only !== void 0 && isValueNode(only) && only !== null && typeof only !== "boolean") return typeof only === "string" ? only : tokens.value(only);
|
|
879
|
+
return nodes.map((node) => isValueNode(node) ? typeof node === "string" ? node : tokens.value(node) : tokens.nodes([node]));
|
|
880
|
+
};
|
|
881
|
+
/**
|
|
882
|
+
* Expand one semantic document through the existing email primitives. Input expressions remain
|
|
883
|
+
* expressions: expansion does not evaluate input, call the compiler or resolve any stylesheet.
|
|
884
|
+
* Both compiler CSS preparation and send-time rendering use this exact lowering.
|
|
885
|
+
*/
|
|
886
|
+
const lowerEmailNodes = (nodes, brandPlugin = samvaBrandPlugin, report) => {
|
|
887
|
+
const components = {
|
|
888
|
+
Email,
|
|
889
|
+
Section,
|
|
890
|
+
Columns,
|
|
891
|
+
Column,
|
|
892
|
+
Button,
|
|
893
|
+
Spacer,
|
|
894
|
+
Divider,
|
|
895
|
+
Image,
|
|
896
|
+
Link
|
|
897
|
+
};
|
|
898
|
+
const walk = (list) => list.flatMap((node) => {
|
|
899
|
+
if (isValueNode(node)) return [node];
|
|
900
|
+
if ("type" in node) {
|
|
901
|
+
if (node.type !== "component") {
|
|
902
|
+
const { type: _type, tag, children, ...rest } = node;
|
|
903
|
+
return [{
|
|
904
|
+
el: tag,
|
|
905
|
+
...rest,
|
|
906
|
+
...children === void 0 ? {} : { children: walk(children) }
|
|
907
|
+
}];
|
|
908
|
+
}
|
|
909
|
+
const kids = walk(node.children ?? []);
|
|
910
|
+
const tokens = new Tokens();
|
|
911
|
+
const props = {};
|
|
912
|
+
for (const [name, value] of Object.entries(node.props ?? {})) {
|
|
913
|
+
if (node.name === "Columns" && (name === "each" || name === "per" || name === "stack" && node.props?.each !== void 0)) continue;
|
|
914
|
+
props[name] = prop(tokens, value);
|
|
915
|
+
}
|
|
916
|
+
if (node.name === "Column") {
|
|
917
|
+
if (props.width === void 0 && node.emailColumn?.width !== void 0) props.width = propFor(tokens, node.emailColumn.width);
|
|
918
|
+
if (node.emailColumn?.stack === true) props.className = typeof props.className === "string" && props.className !== "" ? `${PRIMITIVE_CLASSES.stack} ${props.className}` : PRIMITIVE_CLASSES.stack;
|
|
919
|
+
}
|
|
920
|
+
if (kids.length > 0) props.children = childrenProp(tokens, kids);
|
|
921
|
+
if (node.name === "Button" && typeof props.children === "string" && props.children !== "" && props.width === void 0 && props.height !== void 0 && tokens.toValue(props.children) !== props.children) report?.({
|
|
922
|
+
code: "button-dynamic-label",
|
|
923
|
+
severity: "error",
|
|
924
|
+
message: "A button with a dynamic label requires an explicit width for its Outlook fallback."
|
|
925
|
+
});
|
|
926
|
+
const output = (node.name === "BrandLogo" || node.name === "BrandFooter" ? brandComponents({
|
|
927
|
+
...node.brand,
|
|
928
|
+
css: ""
|
|
929
|
+
}, brandPlugin)[node.name] : components[node.name])(props);
|
|
930
|
+
if (output !== null) for (const diagnostic of collectEmailDiagnostics(output)) report?.(diagnostic);
|
|
931
|
+
return [...emailNodeToIr(output, tokens, node.src)];
|
|
932
|
+
}
|
|
933
|
+
if ("el" in node) return [{
|
|
934
|
+
...node,
|
|
935
|
+
...node.children === void 0 ? {} : { children: walk(node.children) }
|
|
936
|
+
}];
|
|
937
|
+
if ("each" in node) return [{
|
|
938
|
+
...node,
|
|
939
|
+
children: walk(node.children)
|
|
940
|
+
}];
|
|
941
|
+
if ("if" in node) return [{
|
|
942
|
+
...node,
|
|
943
|
+
then: walk(node.then),
|
|
944
|
+
...node.else === void 0 ? {} : { else: walk(node.else) }
|
|
945
|
+
}];
|
|
946
|
+
return [node];
|
|
947
|
+
});
|
|
948
|
+
return walk(nodes);
|
|
949
|
+
};
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/lower/web.ts
|
|
952
|
+
/** Web-only defaults live below project utilities; explicit props are emitted separately. */
|
|
953
|
+
const WEB_BASE_STYLES = [{
|
|
954
|
+
at: "layer sml-web-components",
|
|
955
|
+
children: [
|
|
956
|
+
{
|
|
957
|
+
selector: ":where(.sml-web-email,.sml-web-email *)",
|
|
958
|
+
style: [["box-sizing", "border-box"]]
|
|
959
|
+
},
|
|
960
|
+
{
|
|
961
|
+
selector: ".sml-web-email",
|
|
962
|
+
style: [
|
|
963
|
+
["color", "var(--color-foreground)"],
|
|
964
|
+
["font-family", "var(--font-sans)"],
|
|
965
|
+
["background-color", "var(--color-background)"],
|
|
966
|
+
["padding-inline", "16px"]
|
|
967
|
+
]
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
selector: ".sml-web-section",
|
|
971
|
+
style: [["width", "100%"], ["margin-inline", "auto"]]
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
selector: ".sml-web-columns",
|
|
975
|
+
style: [
|
|
976
|
+
["display", "grid"],
|
|
977
|
+
["grid-auto-flow", "column"],
|
|
978
|
+
["grid-auto-columns", "minmax(0,1fr)"],
|
|
979
|
+
["grid-template-columns", "var(--sml-web-columns,none)"],
|
|
980
|
+
["width", "100%"],
|
|
981
|
+
["margin-inline", "auto"]
|
|
982
|
+
]
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
selector: ".sml-web-column",
|
|
986
|
+
style: [["min-width", "0"]]
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
selector: ".sml-web-button",
|
|
990
|
+
style: [
|
|
991
|
+
["display", "inline-flex"],
|
|
992
|
+
["align-items", "center"],
|
|
993
|
+
["justify-content", "center"],
|
|
994
|
+
["box-sizing", "border-box"],
|
|
995
|
+
["background-color", "var(--color-primary)"],
|
|
996
|
+
["color", "var(--color-primary-foreground)"],
|
|
997
|
+
["font-family", "var(--font-sans)"],
|
|
998
|
+
["border-radius", "var(--radius)"],
|
|
999
|
+
["font-size", "16px"],
|
|
1000
|
+
["font-weight", "bold"],
|
|
1001
|
+
["line-height", "1.2"],
|
|
1002
|
+
["padding-inline", "16px"],
|
|
1003
|
+
["text-align", "center"],
|
|
1004
|
+
["text-decoration", "none"],
|
|
1005
|
+
["max-width", "100%"]
|
|
1006
|
+
]
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
selector: ".sml-web-link",
|
|
1010
|
+
style: [["color", "var(--color-primary)"], ["text-decoration", "underline"]]
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
selector: ".sml-web-divider",
|
|
1014
|
+
style: [["border", "0"], ["border-top", "1px solid var(--color-border)"]]
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
selector: ".sml-web-image",
|
|
1018
|
+
style: [
|
|
1019
|
+
["display", "block"],
|
|
1020
|
+
["max-width", "100%"],
|
|
1021
|
+
["height", "auto"]
|
|
1022
|
+
]
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
selector: ".sml-web-logo-dark",
|
|
1026
|
+
style: [["display", "none"]]
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
selector: ".sml-web-footer",
|
|
1030
|
+
style: [
|
|
1031
|
+
["padding", "32px 16px"],
|
|
1032
|
+
["font-size", "0.75rem"],
|
|
1033
|
+
["color", "var(--color-muted-foreground)"],
|
|
1034
|
+
["text-align", "center"]
|
|
1035
|
+
]
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
selector: ".sml-web-footer address",
|
|
1039
|
+
style: [["font-style", "normal"]]
|
|
1040
|
+
},
|
|
1041
|
+
{
|
|
1042
|
+
at: "media (max-width:599px)",
|
|
1043
|
+
children: [{
|
|
1044
|
+
selector: ".sml-web-columns-stack",
|
|
1045
|
+
style: [["grid-auto-flow", "row"], ["grid-template-columns", "minmax(0,1fr)"]]
|
|
1046
|
+
}, {
|
|
1047
|
+
selector: ".sml-web-columns-stack > .sml-web-column",
|
|
1048
|
+
style: [["width", "100%!important"], ["max-width", "100%!important"]]
|
|
1049
|
+
}]
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
at: "media (prefers-color-scheme:dark)",
|
|
1053
|
+
children: [{
|
|
1054
|
+
selector: ".sml-web-logo-light",
|
|
1055
|
+
style: [["display", "none"]]
|
|
1056
|
+
}, {
|
|
1057
|
+
selector: ".sml-web-logo-dark",
|
|
1058
|
+
style: [["display", "block"]]
|
|
1059
|
+
}]
|
|
1060
|
+
}
|
|
1061
|
+
]
|
|
1062
|
+
}];
|
|
1063
|
+
/** Only the semantic web tags added by lowering extend the shared serializer vocabulary. */
|
|
1064
|
+
const webElement = (tag) => [
|
|
1065
|
+
"main",
|
|
1066
|
+
"section",
|
|
1067
|
+
"footer",
|
|
1068
|
+
"address"
|
|
1069
|
+
].includes(tag) ? emailElement("div") : emailElement(tag);
|
|
1070
|
+
const value = (prop) => typeof prop === "object" && prop !== null && "object" in prop ? void 0 : prop;
|
|
1071
|
+
const choose = (prop, expected, yes, no) => ({
|
|
1072
|
+
if: {
|
|
1073
|
+
cmp: "eq",
|
|
1074
|
+
args: [prop, expected]
|
|
1075
|
+
},
|
|
1076
|
+
then: yes,
|
|
1077
|
+
else: no
|
|
1078
|
+
});
|
|
1079
|
+
const styleFields = (prop) => {
|
|
1080
|
+
if (typeof prop !== "object" || prop === null || !("object" in prop)) return [];
|
|
1081
|
+
return Object.entries(prop.object).flatMap(([key, field]) => {
|
|
1082
|
+
const resolved = value(field);
|
|
1083
|
+
return resolved === void 0 ? [] : [[key, resolved]];
|
|
1084
|
+
});
|
|
1085
|
+
};
|
|
1086
|
+
const layoutStyle = (entries) => entries.filter(([property]) => !/^(?:mso|borderCollapse$|borderSpacing$|tableLayout$|border-collapse$|border-spacing$|table-layout$)/i.test(property));
|
|
1087
|
+
/** Lower identity and layout only. Shared renderer evaluation resolves all bindings and loops. */
|
|
1088
|
+
const lowerWebNodes = (nodes, plugin, reject, presentation) => {
|
|
1089
|
+
const headHints = /* @__PURE__ */ new Map();
|
|
1090
|
+
const stylesheets = /* @__PURE__ */ new Map();
|
|
1091
|
+
const gridTracks = /* @__PURE__ */ new Map();
|
|
1092
|
+
let stylesheet = 0;
|
|
1093
|
+
const titleNodes = (list) => list.flatMap((node) => {
|
|
1094
|
+
if (isValueNode(node)) return [];
|
|
1095
|
+
if ("if" in node) {
|
|
1096
|
+
const then = titleNodes(node.then);
|
|
1097
|
+
const otherwise = titleNodes(node.else ?? []);
|
|
1098
|
+
return then.length === 0 && otherwise.length === 0 ? [] : [{
|
|
1099
|
+
...node,
|
|
1100
|
+
then,
|
|
1101
|
+
...node.else === void 0 ? {} : { else: otherwise }
|
|
1102
|
+
}];
|
|
1103
|
+
}
|
|
1104
|
+
if ("each" in node) {
|
|
1105
|
+
const children = titleNodes(node.children);
|
|
1106
|
+
return children.length === 0 ? [] : [{
|
|
1107
|
+
...node,
|
|
1108
|
+
children
|
|
1109
|
+
}];
|
|
1110
|
+
}
|
|
1111
|
+
if ("type" in node && node.type === "component") return [];
|
|
1112
|
+
if ("type" in node || "el" in node) {
|
|
1113
|
+
const tag = "type" in node ? node.tag : node.el;
|
|
1114
|
+
if (tag === "title") return [...node.children ?? []];
|
|
1115
|
+
if (tag === "head") return titleNodes(node.children ?? []);
|
|
1116
|
+
}
|
|
1117
|
+
return [];
|
|
1118
|
+
});
|
|
1119
|
+
const walk = (list, sizedTracks = false) => list.flatMap((node) => {
|
|
1120
|
+
if (isValueNode(node)) return [node];
|
|
1121
|
+
if ("type" in node && node.type === "component") return component(node, sizedTracks);
|
|
1122
|
+
if ("type" in node || "el" in node) {
|
|
1123
|
+
const tag = "type" in node ? node.tag : node.el;
|
|
1124
|
+
if (tag === "head") return walk(node.children ?? []);
|
|
1125
|
+
if (tag === "title" || tag === "meta") return [];
|
|
1126
|
+
if (tag === "style") {
|
|
1127
|
+
const marker = { el: "style" };
|
|
1128
|
+
const rules = presentation?.stylesheets?.[stylesheet++];
|
|
1129
|
+
if (rules === void 0) return reject("Authored styles require a compiler-parsed web stylesheet.");
|
|
1130
|
+
stylesheets.set(marker, rules);
|
|
1131
|
+
return [marker];
|
|
1132
|
+
}
|
|
1133
|
+
if (["script", "link"].includes(tag)) return reject("Web bodies cannot contain raw text or shell metadata elements.");
|
|
1134
|
+
const mapped = tag === "html" ? "main" : tag === "body" ? "div" : tag;
|
|
1135
|
+
const attrs = tag === "html" ? Object.fromEntries(Object.entries(node.attrs ?? {}).filter(([key]) => !key.startsWith("xmlns"))) : tag === "body" ? Object.fromEntries(Object.entries(node.attrs ?? {}).filter(([key]) => key !== "bgcolor")) : node.attrs;
|
|
1136
|
+
const output = {
|
|
1137
|
+
el: mapped,
|
|
1138
|
+
...attrs === void 0 ? {} : { attrs },
|
|
1139
|
+
...node.style === void 0 ? {} : { style: node.style },
|
|
1140
|
+
...node.src === void 0 ? {} : { src: node.src },
|
|
1141
|
+
...node.children === void 0 ? {} : { children: walk(node.children) }
|
|
1142
|
+
};
|
|
1143
|
+
if (tag === "html") headHints.set(output, {
|
|
1144
|
+
...node.attrs?.lang === void 0 ? {} : { lang: node.attrs.lang },
|
|
1145
|
+
...node.attrs?.dir === void 0 ? {} : { dir: node.attrs.dir },
|
|
1146
|
+
titleNodes: titleNodes(node.children ?? [])
|
|
1147
|
+
});
|
|
1148
|
+
if (tag === "body" && node.attrs?.bgcolor !== void 0) return [{
|
|
1149
|
+
el: "div",
|
|
1150
|
+
style: [["backgroundColor", node.attrs.bgcolor]],
|
|
1151
|
+
children: [output],
|
|
1152
|
+
...node.src === void 0 ? {} : { src: node.src }
|
|
1153
|
+
}];
|
|
1154
|
+
return [output];
|
|
1155
|
+
}
|
|
1156
|
+
if ("each" in node) return [{
|
|
1157
|
+
...node,
|
|
1158
|
+
children: walk(node.children, sizedTracks)
|
|
1159
|
+
}];
|
|
1160
|
+
if ("if" in node) return [{
|
|
1161
|
+
...node,
|
|
1162
|
+
then: walk(node.then, sizedTracks),
|
|
1163
|
+
...node.else === void 0 ? {} : { else: walk(node.else, sizedTracks) }
|
|
1164
|
+
}];
|
|
1165
|
+
if ("preheader" in node) return [];
|
|
1166
|
+
return reject("Raw email compatibility markup cannot be rendered to the web.");
|
|
1167
|
+
});
|
|
1168
|
+
const component = (node, sizedTracks) => {
|
|
1169
|
+
const p = node.props ?? {};
|
|
1170
|
+
const get = (key) => {
|
|
1171
|
+
const field = p[key];
|
|
1172
|
+
if (typeof field === "object" && field !== null && "object" in field) return reject(`Component prop ${key} must be a scalar expression.`);
|
|
1173
|
+
return field;
|
|
1174
|
+
};
|
|
1175
|
+
for (const key of ["style", "tableStyle"]) {
|
|
1176
|
+
const field = p[key];
|
|
1177
|
+
if (field !== void 0 && (typeof field !== "object" || field === null || !("object" in field))) return reject(`Component ${key} must contain named declarations.`);
|
|
1178
|
+
if (typeof field === "object" && field !== null && "object" in field && Object.values(field.object).some((v) => typeof v === "object" && v !== null && "object" in v)) return reject("Style declarations cannot contain nested objects.");
|
|
1179
|
+
}
|
|
1180
|
+
const attrs = {};
|
|
1181
|
+
const css = /* @__PURE__ */ new Map();
|
|
1182
|
+
const set = (key, v) => {
|
|
1183
|
+
if (v !== void 0) css.set(key, v);
|
|
1184
|
+
};
|
|
1185
|
+
const copy = (...keys) => {
|
|
1186
|
+
for (const key of keys) {
|
|
1187
|
+
const v = get(key);
|
|
1188
|
+
if (v !== void 0) attrs[key] = v;
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
const style = (...keys) => {
|
|
1192
|
+
for (const key of keys) set(key, get(key));
|
|
1193
|
+
};
|
|
1194
|
+
const alignment = (v) => {
|
|
1195
|
+
if (v === void 0) return;
|
|
1196
|
+
set("marginLeft", choose(v, "right", "auto", choose(v, "center", "auto", "0")));
|
|
1197
|
+
set("marginRight", choose(v, "left", "auto", choose(v, "center", "auto", "0")));
|
|
1198
|
+
};
|
|
1199
|
+
let tag;
|
|
1200
|
+
let own = `sml-web-${node.name.toLowerCase()}`;
|
|
1201
|
+
const fixedTracks = node.name === "Columns" && (node.children?.length ?? 0) > 0 && node.children.every((kid) => typeof kid === "object" && kid !== null && "type" in kid && kid.type === "component" && kid.name === "Column");
|
|
1202
|
+
let kids = walk(node.children ?? [], fixedTracks);
|
|
1203
|
+
if ((node.children?.length ?? 0) === 0) {
|
|
1204
|
+
const content = get("children");
|
|
1205
|
+
if (content !== void 0) kids = [content];
|
|
1206
|
+
}
|
|
1207
|
+
const child = (el, children, extra = {}) => ({
|
|
1208
|
+
el,
|
|
1209
|
+
children,
|
|
1210
|
+
...extra,
|
|
1211
|
+
...node.src === void 0 ? {} : { src: node.src }
|
|
1212
|
+
});
|
|
1213
|
+
switch (node.name) {
|
|
1214
|
+
case "Email":
|
|
1215
|
+
tag = "main";
|
|
1216
|
+
own += " sml-email";
|
|
1217
|
+
copy("lang", "dir");
|
|
1218
|
+
style("backgroundColor");
|
|
1219
|
+
set("paddingInline", get("gutter"));
|
|
1220
|
+
break;
|
|
1221
|
+
case "Section":
|
|
1222
|
+
tag = "section";
|
|
1223
|
+
set("maxWidth", get("width"));
|
|
1224
|
+
set("backgroundColor", get("bgcolor"));
|
|
1225
|
+
alignment(get("align"));
|
|
1226
|
+
for (const [key, field] of layoutStyle(styleFields(p.tableStyle))) set(key, field);
|
|
1227
|
+
break;
|
|
1228
|
+
case "Columns":
|
|
1229
|
+
tag = "div";
|
|
1230
|
+
if (get("stack") !== false) own += " sml-web-columns-stack";
|
|
1231
|
+
set("maxWidth", get("width"));
|
|
1232
|
+
set("gap", get("gap"));
|
|
1233
|
+
alignment(get("align"));
|
|
1234
|
+
if (typeof p.per === "number") set("--sml-web-columns", `repeat(${p.per},minmax(0,1fr))`);
|
|
1235
|
+
else if (fixedTracks) {
|
|
1236
|
+
const widths = node.children.map((kid) => {
|
|
1237
|
+
if (typeof kid !== "object" || kid === null || !("type" in kid) || kid.type !== "component") return void 0;
|
|
1238
|
+
return value(kid.props?.width);
|
|
1239
|
+
});
|
|
1240
|
+
const tracks = { concat: widths.flatMap((width, index) => [...index === 0 ? [] : [" "], width ?? "minmax(0,1fr)"]) };
|
|
1241
|
+
gridTracks.set(tracks, widths);
|
|
1242
|
+
set("--sml-web-columns", tracks);
|
|
1243
|
+
}
|
|
1244
|
+
break;
|
|
1245
|
+
case "Column": {
|
|
1246
|
+
tag = "div";
|
|
1247
|
+
const width = get("width");
|
|
1248
|
+
if (!sizedTracks) set("width", width);
|
|
1249
|
+
set("textAlign", get("align"));
|
|
1250
|
+
if (get("valign") !== void 0) set("alignSelf", choose(get("valign"), "middle", "center", choose(get("valign"), "bottom", "end", "start")));
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1253
|
+
case "Button":
|
|
1254
|
+
tag = "a";
|
|
1255
|
+
own += " sml-button";
|
|
1256
|
+
copy("href");
|
|
1257
|
+
style("width", "height", "backgroundColor", "color", "borderRadius", "borderColor", "borderWidth", "fontSize", "fontFamily");
|
|
1258
|
+
set("paddingInline", get("paddingX"));
|
|
1259
|
+
if (get("borderColor") !== void 0) set("borderStyle", "solid");
|
|
1260
|
+
alignment(get("align"));
|
|
1261
|
+
if (get("align") !== void 0) {
|
|
1262
|
+
set("display", "flex");
|
|
1263
|
+
set("width", get("width") ?? "fit-content");
|
|
1264
|
+
}
|
|
1265
|
+
break;
|
|
1266
|
+
case "Spacer":
|
|
1267
|
+
tag = "div";
|
|
1268
|
+
attrs["aria-hidden"] = "true";
|
|
1269
|
+
style("height");
|
|
1270
|
+
kids = [];
|
|
1271
|
+
break;
|
|
1272
|
+
case "Divider":
|
|
1273
|
+
tag = "hr";
|
|
1274
|
+
own += " sml-divider";
|
|
1275
|
+
set("borderTopColor", get("color"));
|
|
1276
|
+
set("borderTopWidth", get("thickness"));
|
|
1277
|
+
break;
|
|
1278
|
+
case "Image":
|
|
1279
|
+
tag = "img";
|
|
1280
|
+
copy("src", "alt", "width", "height");
|
|
1281
|
+
alignment(get("align"));
|
|
1282
|
+
break;
|
|
1283
|
+
case "Link":
|
|
1284
|
+
tag = "a";
|
|
1285
|
+
own += " sml-link";
|
|
1286
|
+
copy("href", "target", "rel");
|
|
1287
|
+
break;
|
|
1288
|
+
case "BrandLogo": {
|
|
1289
|
+
const brand = node.brand;
|
|
1290
|
+
if (brand.assets?.logoUrl === void 0) return [];
|
|
1291
|
+
const alt = get("alt") ?? brand.footer?.companyName ?? "";
|
|
1292
|
+
const image = (src, dark) => child("img", [], { attrs: {
|
|
1293
|
+
src,
|
|
1294
|
+
alt,
|
|
1295
|
+
width: get("width") ?? 120,
|
|
1296
|
+
class: { concat: [`sml-web-image${brand.assets?.logoDarkUrl === void 0 ? "" : dark ? " sml-web-logo-dark" : " sml-web-logo-light"}`, ...get("className") === void 0 ? [] : [" ", get("className")]] }
|
|
1297
|
+
} });
|
|
1298
|
+
tag = "div";
|
|
1299
|
+
if (get("align") !== void 0) {
|
|
1300
|
+
set("display", "grid");
|
|
1301
|
+
set("justifyItems", choose(get("align"), "right", "end", choose(get("align"), "center", "center", "start")));
|
|
1302
|
+
}
|
|
1303
|
+
kids = [image(brand.assets.logoUrl, false), ...brand.assets.logoDarkUrl === void 0 ? [] : [image(brand.assets.logoDarkUrl, true)]];
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
case "BrandFooter": {
|
|
1307
|
+
tag = "footer";
|
|
1308
|
+
own = "sml-web-footer";
|
|
1309
|
+
attrs[plugin.footerAttribute] = "";
|
|
1310
|
+
const footer = node.brand.footer ?? {};
|
|
1311
|
+
const items = [];
|
|
1312
|
+
const add = (item) => {
|
|
1313
|
+
if (items.length > 0) items.push(" · ");
|
|
1314
|
+
items.push(item);
|
|
1315
|
+
};
|
|
1316
|
+
if (footer.companyName?.trim()) add(child("strong", [footer.companyName]));
|
|
1317
|
+
for (const link of footer.socialLinks ?? []) add(child("a", [link.label], { attrs: { href: link.url } }));
|
|
1318
|
+
const unsubscribe = child("span", [...items.length === 0 ? [] : [" · "], child("a", [get("unsubscribeLabel") ?? "Unsubscribe"], {
|
|
1319
|
+
attrs: { href: plugin.unsubscribeUrlPlaceholder },
|
|
1320
|
+
reserved: true
|
|
1321
|
+
})], {
|
|
1322
|
+
attrs: { [plugin.unsubscribeRowAttribute]: "" },
|
|
1323
|
+
reserved: true
|
|
1324
|
+
});
|
|
1325
|
+
kids = [child("p", [...items, unsubscribe])];
|
|
1326
|
+
if (footer.address !== void 0) {
|
|
1327
|
+
const a = footer.address;
|
|
1328
|
+
const lines = [
|
|
1329
|
+
a.street1,
|
|
1330
|
+
a.street2,
|
|
1331
|
+
[
|
|
1332
|
+
a.city,
|
|
1333
|
+
a.region,
|
|
1334
|
+
a.postalCode
|
|
1335
|
+
].filter((part) => part.trim() !== "").join(", "),
|
|
1336
|
+
a.country
|
|
1337
|
+
].filter((line) => line !== void 0 && line.trim() !== "");
|
|
1338
|
+
kids.push(child("address", lines.flatMap((line, i) => i === 0 ? [line] : [child("br", []), line])));
|
|
1339
|
+
}
|
|
1340
|
+
break;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
const authored = node.name === "BrandLogo" ? void 0 : get("className");
|
|
1344
|
+
attrs.class = authored === void 0 ? own : { concat: [
|
|
1345
|
+
own,
|
|
1346
|
+
" ",
|
|
1347
|
+
authored
|
|
1348
|
+
] };
|
|
1349
|
+
for (const [key, field] of layoutStyle(styleFields(p.style))) set(key, field);
|
|
1350
|
+
const output = {
|
|
1351
|
+
el: tag,
|
|
1352
|
+
attrs,
|
|
1353
|
+
...css.size === 0 ? {} : { style: [...css] },
|
|
1354
|
+
...kids.length === 0 ? {} : { children: kids },
|
|
1355
|
+
...node.name === "BrandFooter" ? { reserved: true } : {},
|
|
1356
|
+
...node.src === void 0 ? {} : { src: node.src }
|
|
1357
|
+
};
|
|
1358
|
+
if (node.name === "Email") headHints.set(output, {
|
|
1359
|
+
...get("title") === void 0 ? {} : { title: get("title") },
|
|
1360
|
+
lang: get("lang") ?? "en",
|
|
1361
|
+
dir: get("dir") ?? "ltr"
|
|
1362
|
+
});
|
|
1363
|
+
return [output];
|
|
1364
|
+
};
|
|
1365
|
+
return {
|
|
1366
|
+
nodes: walk(nodes),
|
|
1367
|
+
headHints,
|
|
1368
|
+
stylesheets,
|
|
1369
|
+
gridTracks
|
|
1370
|
+
};
|
|
1371
|
+
};
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region src/sms-segments.ts
|
|
1374
|
+
const GSM7_EXTENDED = /* @__PURE__ */ new Set([
|
|
1375
|
+
"[",
|
|
1376
|
+
"]",
|
|
1377
|
+
"{",
|
|
1378
|
+
"}",
|
|
1379
|
+
"\\",
|
|
1380
|
+
"~",
|
|
1381
|
+
"|",
|
|
1382
|
+
"€",
|
|
1383
|
+
"^",
|
|
1384
|
+
"\f"
|
|
1385
|
+
]);
|
|
1386
|
+
const GSM7_BASIC_PATTERN = /^[@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&'()*+,\-./:;<=>?¡0-9ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà]*$/;
|
|
1387
|
+
const containsNonGsmCharacters = (text) => {
|
|
1388
|
+
for (const char of text) {
|
|
1389
|
+
if (GSM7_EXTENDED.has(char)) continue;
|
|
1390
|
+
if (!GSM7_BASIC_PATTERN.test(char)) return true;
|
|
1391
|
+
}
|
|
1392
|
+
return false;
|
|
1393
|
+
};
|
|
1394
|
+
const smsSegmentInfo = (text) => {
|
|
1395
|
+
if (containsNonGsmCharacters(text)) {
|
|
1396
|
+
const codePoints = [...text].length;
|
|
1397
|
+
const segmentCount = codePoints <= 70 ? 1 : Math.ceil(codePoints / 67);
|
|
1398
|
+
return {
|
|
1399
|
+
encoding: "ucs2",
|
|
1400
|
+
characterCount: text.length,
|
|
1401
|
+
segmentCount
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
let encodedLength = 0;
|
|
1405
|
+
for (const char of text) encodedLength += GSM7_EXTENDED.has(char) ? 2 : 1;
|
|
1406
|
+
const segmentCount = encodedLength <= 160 ? 1 : Math.ceil(encodedLength / 153);
|
|
1407
|
+
return {
|
|
1408
|
+
encoding: "gsm7",
|
|
1409
|
+
characterCount: text.length,
|
|
1410
|
+
segmentCount
|
|
1411
|
+
};
|
|
1412
|
+
};
|
|
1413
|
+
//#endregion
|
|
1414
|
+
//#region src/render-template.ts
|
|
1415
|
+
var RenderError = class extends TaggedError("RenderError") {
|
|
1416
|
+
constructor(args) {
|
|
1417
|
+
super({
|
|
1418
|
+
diagnostics: args.diagnostics,
|
|
1419
|
+
message: `Template render rejected: ${args.diagnostics.map((item) => `${item.code}: ${item.message}`).join("; ")}`
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
const fail = (code, message) => {
|
|
1424
|
+
throw new RenderError({ diagnostics: [{
|
|
1425
|
+
code,
|
|
1426
|
+
message
|
|
1427
|
+
}] });
|
|
1428
|
+
};
|
|
1429
|
+
const MAX_ITERATIONS = 1e4;
|
|
1430
|
+
const MAX_DEPTH = 64;
|
|
1431
|
+
/** Classic Outlook lays a document out correctly only under a transitional doctype. */
|
|
1432
|
+
const EMAIL_DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
|
|
1433
|
+
const PREHEADER_TARGET_LENGTH$1 = 200;
|
|
1434
|
+
const PREHEADER_PADDING$1 = "\xA0";
|
|
1435
|
+
const escapeText$1 = (value) => value.replace(/[&<>]/g, (character) => ({
|
|
1436
|
+
"&": "&",
|
|
1437
|
+
"<": "<",
|
|
1438
|
+
">": ">"
|
|
1439
|
+
})[character] ?? character);
|
|
1440
|
+
const escapeAttribute$1 = (value) => value.replace(/[&<>"']/g, (character) => ({
|
|
1441
|
+
"&": "&",
|
|
1442
|
+
"<": "<",
|
|
1443
|
+
">": ">",
|
|
1444
|
+
"\"": """,
|
|
1445
|
+
"'": "'"
|
|
1446
|
+
})[character] ?? character);
|
|
1447
|
+
const CONTROL_CHARACTERS$1 = /[\u0000-\u001f\u007f]/;
|
|
1448
|
+
const SAFE_URL_SCHEME$1 = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
|
|
1449
|
+
/** A delivered message can resolve only an absolute URL, so relative references are refused. */
|
|
1450
|
+
const isSafeUrl$1 = (value) => !/^[\u0000- ]/.test(value) && !CONTROL_CHARACTERS$1.test(value) && !value.includes("\\") && SAFE_URL_SCHEME$1.test(value);
|
|
1451
|
+
const CSS_PROPERTY$1 = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
|
|
1452
|
+
const CSS_UNSAFE$1 = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
|
|
1453
|
+
const CSS_URL$1 = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
|
|
1454
|
+
const kebab$1 = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
|
|
1455
|
+
const UNITLESS$1 = /* @__PURE__ */ new Set([
|
|
1456
|
+
"opacity",
|
|
1457
|
+
"zIndex",
|
|
1458
|
+
"fontWeight",
|
|
1459
|
+
"lineHeight",
|
|
1460
|
+
"order",
|
|
1461
|
+
"flexGrow",
|
|
1462
|
+
"flexShrink",
|
|
1463
|
+
"columnCount",
|
|
1464
|
+
"orphans",
|
|
1465
|
+
"widows",
|
|
1466
|
+
"zoom",
|
|
1467
|
+
"fillOpacity",
|
|
1468
|
+
"strokeOpacity"
|
|
1469
|
+
]);
|
|
1470
|
+
const declarationValue$1 = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS$1.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
|
|
1471
|
+
const webWidth = (value) => typeof value === "number" || /^\d+(?:\.\d+)?$/.test(value) ? `${value}px` : value;
|
|
1472
|
+
const report$1 = (ctx, code, message) => {
|
|
1473
|
+
ctx.diagnostics.push({
|
|
1474
|
+
code,
|
|
1475
|
+
message
|
|
1476
|
+
});
|
|
1477
|
+
};
|
|
1478
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1479
|
+
const has = (value, key) => Object.hasOwn(value, key);
|
|
1480
|
+
const FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set([
|
|
1481
|
+
"__proto__",
|
|
1482
|
+
"constructor",
|
|
1483
|
+
"prototype"
|
|
1484
|
+
]);
|
|
1485
|
+
const ARRAY_INDEX = /^(0|[1-9]\d*)$/;
|
|
1486
|
+
const step = (value, segment) => {
|
|
1487
|
+
if (FORBIDDEN_SEGMENTS.has(segment)) return void 0;
|
|
1488
|
+
if (Array.isArray(value)) return ARRAY_INDEX.test(segment) && Object.hasOwn(value, segment) ? value[Number(segment)] : void 0;
|
|
1489
|
+
if (isRecord(value) && Object.hasOwn(value, segment)) return value[segment];
|
|
1490
|
+
};
|
|
1491
|
+
const resolvePath = (ctx, scope, path) => {
|
|
1492
|
+
if (typeof path !== "string" || path === "") return void 0;
|
|
1493
|
+
const [first = "", ...rest] = path.split(".");
|
|
1494
|
+
if (FORBIDDEN_SEGMENTS.has(first)) return void 0;
|
|
1495
|
+
let current = first.startsWith("$") ? scope.get(first) : step(ctx.input, first);
|
|
1496
|
+
for (const segment of rest) {
|
|
1497
|
+
if (current === void 0 || current === null) return void 0;
|
|
1498
|
+
current = step(current, segment);
|
|
1499
|
+
}
|
|
1500
|
+
return current;
|
|
1501
|
+
};
|
|
1502
|
+
const invalid = (message) => fail("invalid-value", message);
|
|
1503
|
+
/** The text a value contributes: strings as is, finite numbers, nothing for null and booleans. */
|
|
1504
|
+
const textOf = (value) => {
|
|
1505
|
+
if (typeof value === "string") return value;
|
|
1506
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : invalid("A non-finite number cannot be text.");
|
|
1507
|
+
if (value === null || value === void 0 || typeof value === "boolean") return "";
|
|
1508
|
+
return invalid("An object or array cannot be rendered as text.");
|
|
1509
|
+
};
|
|
1510
|
+
const finiteNumber = (value, what) => typeof value === "number" && Number.isFinite(value) ? value : invalid(`${what} must be a finite number.`);
|
|
1511
|
+
const arithmetic = (op, left, right) => {
|
|
1512
|
+
if ((op === "/" || op === "%") && right === 0) return invalid("Division by zero.");
|
|
1513
|
+
let result;
|
|
1514
|
+
switch (op) {
|
|
1515
|
+
case "+":
|
|
1516
|
+
result = left + right;
|
|
1517
|
+
break;
|
|
1518
|
+
case "-":
|
|
1519
|
+
result = left - right;
|
|
1520
|
+
break;
|
|
1521
|
+
case "*":
|
|
1522
|
+
result = left * right;
|
|
1523
|
+
break;
|
|
1524
|
+
case "/":
|
|
1525
|
+
result = left / right;
|
|
1526
|
+
break;
|
|
1527
|
+
case "%":
|
|
1528
|
+
result = left % right;
|
|
1529
|
+
break;
|
|
1530
|
+
default: return invalid(`Unknown arithmetic operator ${JSON.stringify(op)}.`);
|
|
1531
|
+
}
|
|
1532
|
+
return Number.isFinite(result) ? result : invalid("Arithmetic produced a non-finite number.");
|
|
1533
|
+
};
|
|
1534
|
+
const formatterContext = (ctx) => {
|
|
1535
|
+
ctx.fmt ??= createFmt({
|
|
1536
|
+
locale: ctx.locale,
|
|
1537
|
+
timeZone: ctx.timeZone
|
|
1538
|
+
});
|
|
1539
|
+
return ctx.fmt;
|
|
1540
|
+
};
|
|
1541
|
+
const applyFormat = (ctx, name, args) => {
|
|
1542
|
+
if (typeof name !== "string") return invalid("A format node needs a formatter name.");
|
|
1543
|
+
try {
|
|
1544
|
+
return callFormatter(formatterContext(ctx), name, args);
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
return fail("format-error", error instanceof Error ? error.message : String(error));
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
const nullish = (value) => value === void 0 ? null : value;
|
|
1550
|
+
const compare = (op, left, right) => {
|
|
1551
|
+
switch (op) {
|
|
1552
|
+
case "eq": return nullish(left) === nullish(right);
|
|
1553
|
+
case "ne": return nullish(left) !== nullish(right);
|
|
1554
|
+
case "lt":
|
|
1555
|
+
case "gt":
|
|
1556
|
+
case "le":
|
|
1557
|
+
case "ge": {
|
|
1558
|
+
if (!(typeof left === "number" && typeof right === "number" || typeof left === "string" && typeof right === "string")) return false;
|
|
1559
|
+
const a = left;
|
|
1560
|
+
const b = right;
|
|
1561
|
+
if (op === "lt") return a < b;
|
|
1562
|
+
if (op === "gt") return a > b;
|
|
1563
|
+
return op === "le" ? a <= b : a >= b;
|
|
1564
|
+
}
|
|
1565
|
+
default: return invalid(`Unknown comparison ${JSON.stringify(op)}.`);
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
const checkDepth = (depth) => {
|
|
1569
|
+
if (depth > MAX_DEPTH) fail("render-limit", `Templates nest at most ${MAX_DEPTH} levels deep.`);
|
|
1570
|
+
};
|
|
1571
|
+
const evalArgs = (ctx, scope, args, depth) => Array.isArray(args) ? args.map((arg) => evalValue(ctx, scope, arg, depth + 1)) : invalid("Arguments must be an array.");
|
|
1572
|
+
const evalValue = (ctx, scope, value, depth) => {
|
|
1573
|
+
checkDepth(depth);
|
|
1574
|
+
if (value === null || typeof value !== "object") return value;
|
|
1575
|
+
if (Array.isArray(value)) return invalid("Unknown value node.");
|
|
1576
|
+
const node = value;
|
|
1577
|
+
if (has(node, "bind")) return resolvePath(ctx, scope, node.bind);
|
|
1578
|
+
if (has(node, "lit")) return node.lit;
|
|
1579
|
+
if (has(node, "concat")) return evalArgs(ctx, scope, node.concat, depth).map(textOf).join("");
|
|
1580
|
+
if (has(node, "format")) return applyFormat(ctx, node.format, evalArgs(ctx, scope, node.args, depth));
|
|
1581
|
+
if (has(node, "if")) return evalPredicate(ctx, scope, node.if, depth + 1) ? evalValue(ctx, scope, node.then, depth + 1) : evalValue(ctx, scope, node.else, depth + 1);
|
|
1582
|
+
if (has(node, "op")) {
|
|
1583
|
+
const [left, right] = evalArgs(ctx, scope, node.args, depth);
|
|
1584
|
+
return arithmetic(node.op, finiteNumber(left, "An arithmetic operand"), finiteNumber(right, "An arithmetic operand"));
|
|
1585
|
+
}
|
|
1586
|
+
if (has(node, "count")) {
|
|
1587
|
+
const items = evalValue(ctx, scope, node.count, depth + 1);
|
|
1588
|
+
if (!Array.isArray(items)) return invalid("count applies to an array.");
|
|
1589
|
+
return selectItems(ctx, scope, items, String(node.as), node.where, depth).length;
|
|
1590
|
+
}
|
|
1591
|
+
if (has(node, "length")) {
|
|
1592
|
+
const measured = evalValue(ctx, scope, node.length, depth + 1);
|
|
1593
|
+
return typeof measured === "string" || Array.isArray(measured) ? measured.length : invalid("length applies to a string or an array.");
|
|
1594
|
+
}
|
|
1595
|
+
return invalid("Unknown value node.");
|
|
1596
|
+
};
|
|
1597
|
+
const evalPredicate = (ctx, scope, predicate, depth) => {
|
|
1598
|
+
checkDepth(depth);
|
|
1599
|
+
if (typeof predicate !== "object" || predicate === null || Array.isArray(predicate)) return invalid("Unknown predicate.");
|
|
1600
|
+
const node = predicate;
|
|
1601
|
+
if (has(node, "present")) return Boolean(resolvePath(ctx, scope, node.present));
|
|
1602
|
+
if (has(node, "truthy")) return Boolean(evalValue(ctx, scope, node.truthy, depth + 1));
|
|
1603
|
+
if (has(node, "not")) return !evalPredicate(ctx, scope, node.not, depth + 1);
|
|
1604
|
+
if (has(node, "and")) return asArray(node.and).every((item) => evalPredicate(ctx, scope, item, depth + 1));
|
|
1605
|
+
if (has(node, "or")) return asArray(node.or).some((item) => evalPredicate(ctx, scope, item, depth + 1));
|
|
1606
|
+
if (has(node, "cmp")) {
|
|
1607
|
+
const [left, right] = evalArgs(ctx, scope, node.args, depth);
|
|
1608
|
+
return compare(node.cmp, left, right);
|
|
1609
|
+
}
|
|
1610
|
+
return invalid("Unknown predicate.");
|
|
1611
|
+
};
|
|
1612
|
+
const asArray = (value) => Array.isArray(value) ? value : invalid("Expected an array.");
|
|
1613
|
+
const declarations$1 = (ctx, scope, entries, depth) => {
|
|
1614
|
+
const parts = [];
|
|
1615
|
+
for (const entry of entries) {
|
|
1616
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
1617
|
+
report$1(ctx, "invalid-style", "A style declaration is a [property, value] pair.");
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
const [property, raw] = entry;
|
|
1621
|
+
const tracks = ctx.web?.gridTracks.get(raw);
|
|
1622
|
+
const value = tracks === void 0 ? evalValue(ctx, scope, raw, depth + 1) : tracks.map((track) => {
|
|
1623
|
+
const width = track === void 0 ? void 0 : evalValue(ctx, scope, track, depth + 1);
|
|
1624
|
+
if (width === void 0 || width === null) return "minmax(0,1fr)";
|
|
1625
|
+
if (typeof width !== "string" && !(typeof width === "number" && Number.isFinite(width))) return invalid("A Column width must be a string or finite number.");
|
|
1626
|
+
return webWidth(width);
|
|
1627
|
+
}).join(" ");
|
|
1628
|
+
if (value === void 0 || value === null) continue;
|
|
1629
|
+
if (typeof property !== "string" || !CSS_PROPERTY$1.test(property)) {
|
|
1630
|
+
report$1(ctx, "unsupported-style-property", `Style property ${JSON.stringify(property)} is not a CSS property name.`);
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (typeof value !== "string" && !(typeof value === "number" && Number.isFinite(value))) {
|
|
1634
|
+
report$1(ctx, "invalid-style-value", `Style ${property} must be a string or finite number.`);
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
const text = ctx.web !== void 0 && ["width", "max-width"].includes(kebab$1(property)) && typeof value === "string" ? webWidth(value) : declarationValue$1(property, value);
|
|
1638
|
+
if (CSS_UNSAFE$1.test(text) || CONTROL_CHARACTERS$1.test(text)) {
|
|
1639
|
+
report$1(ctx, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration.`);
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
let unsafeUrl;
|
|
1643
|
+
const urls = [...text.matchAll(CSS_URL$1)];
|
|
1644
|
+
for (const match of urls) {
|
|
1645
|
+
const url = match[1] ?? match[2] ?? (match[3] ?? "").trim();
|
|
1646
|
+
if (!isSafeUrl$1(url)) unsafeUrl = url;
|
|
1647
|
+
}
|
|
1648
|
+
if ([...text.matchAll(/url\(/gi)].length !== urls.length) unsafeUrl = text;
|
|
1649
|
+
if (unsafeUrl !== void 0) {
|
|
1650
|
+
report$1(ctx, "unsafe-url", `Style ${property} points at ${JSON.stringify(unsafeUrl)}; email URLs must use https:, mailto:, tel:, cid: or a fragment.`);
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
parts.push(`${kebab$1(property)}:${text}`);
|
|
1654
|
+
}
|
|
1655
|
+
return parts.join(";");
|
|
1656
|
+
};
|
|
1657
|
+
const styleText = (ctx, scope, style, depth) => {
|
|
1658
|
+
checkDepth(depth);
|
|
1659
|
+
if (typeof style === "string") {
|
|
1660
|
+
if (ctx.web !== void 0) {
|
|
1661
|
+
const fields = style.split(";").filter((part) => part.trim() !== "").map((part) => {
|
|
1662
|
+
const at = part.indexOf(":");
|
|
1663
|
+
if (at < 1) return invalid("A static web style contains property:value declarations.");
|
|
1664
|
+
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
|
|
1665
|
+
});
|
|
1666
|
+
return declarations$1(ctx, scope, fields, depth);
|
|
1667
|
+
}
|
|
1668
|
+
if (CONTROL_CHARACTERS$1.test(style)) {
|
|
1669
|
+
report$1(ctx, "unsafe-style-value", "A style contains control characters.");
|
|
1670
|
+
return "";
|
|
1671
|
+
}
|
|
1672
|
+
return style;
|
|
1673
|
+
}
|
|
1674
|
+
if (Array.isArray(style)) return declarations$1(ctx, scope, style, depth);
|
|
1675
|
+
if (isRecord(style) && has(style, "if")) return evalPredicate(ctx, scope, style.if, depth + 1) ? styleText(ctx, scope, style.then, depth + 1) : styleText(ctx, scope, style.else, depth + 1);
|
|
1676
|
+
report$1(ctx, "invalid-style", "An element style is a string, a declaration list or a conditional.");
|
|
1677
|
+
return "";
|
|
1678
|
+
};
|
|
1679
|
+
const attributeText = (ctx, tag, name, spec, value) => {
|
|
1680
|
+
if (value === void 0 || value === null || value === false) return void 0;
|
|
1681
|
+
if (value === true) return "";
|
|
1682
|
+
if (typeof value !== "string" && !(typeof value === "number" && Number.isFinite(value))) {
|
|
1683
|
+
report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a string or finite number, not ${typeof value}.`);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
const text = String(value);
|
|
1687
|
+
if (CONTROL_CHARACTERS$1.test(text)) {
|
|
1688
|
+
report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (spec.values !== void 0 && !spec.values.includes(text)) {
|
|
1692
|
+
report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`);
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
if (spec.type === "number" && !/^-?\d+$/.test(text)) {
|
|
1696
|
+
report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (spec.type === "measure" && !/^\d+(\.\d+)?%?$/.test(text)) {
|
|
1700
|
+
report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (spec.type === "url" && !isSafeUrl$1(text)) {
|
|
1704
|
+
report$1(ctx, "unsafe-url", `<${tag} ${name}> must use https:, mailto:, tel:, cid: or a fragment; received ${JSON.stringify(text)}. A relative path resolves only in an export, never in a delivered message.`);
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
return text;
|
|
1708
|
+
};
|
|
1709
|
+
const buildAttributes = (ctx, scope, tag, spec, node, props, depth) => {
|
|
1710
|
+
const emitted = [];
|
|
1711
|
+
const present = /* @__PURE__ */ new Set();
|
|
1712
|
+
let classes;
|
|
1713
|
+
const source = node.attrs ?? {};
|
|
1714
|
+
if (!isRecord(source)) {
|
|
1715
|
+
report$1(ctx, "invalid-attribute-value", `<${tag}> attrs must be an object.`);
|
|
1716
|
+
return "";
|
|
1717
|
+
}
|
|
1718
|
+
for (const [key, raw] of Object.entries(source)) {
|
|
1719
|
+
if (key === "key") continue;
|
|
1720
|
+
const name = key === "className" ? "class" : key;
|
|
1721
|
+
const value = evalValue(ctx, scope, raw, depth + 1);
|
|
1722
|
+
props.set(name, value);
|
|
1723
|
+
if (tag === "a" && name === "href" && raw === ctx.brandPlugin.unsubscribeUrlPlaceholder) {
|
|
1724
|
+
if (ctx.web !== void 0 && node.reserved !== true) {
|
|
1725
|
+
report$1(ctx, "reserved-url", "Only a brand footer can write its unsubscribe placeholder.");
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
present.add(name);
|
|
1729
|
+
emitted.push(` href="${escapeAttribute$1(ctx.brandPlugin.unsubscribeUrlPlaceholder)}" ${ctx.brandPlugin.noTrackAttribute}`);
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
if (name === "class") {
|
|
1733
|
+
if (value === void 0 || value === null) continue;
|
|
1734
|
+
if (typeof value !== "string") {
|
|
1735
|
+
report$1(ctx, "invalid-attribute-value", `<${tag} class> must be a string.`);
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
if (CONTROL_CHARACTERS$1.test(value)) {
|
|
1739
|
+
report$1(ctx, "invalid-attribute-value", `<${tag} class> contains control characters.`);
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
if (classes !== void 0) {
|
|
1743
|
+
report$1(ctx, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`);
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1746
|
+
classes = value;
|
|
1747
|
+
emitted.push(` class="${escapeAttribute$1(value)}"`);
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
if (name === "style") {
|
|
1751
|
+
report$1(ctx, "invalid-style", `<${tag}> writes its style on the element, not as an attribute.`);
|
|
1752
|
+
continue;
|
|
1753
|
+
}
|
|
1754
|
+
if (/^on/i.test(name)) {
|
|
1755
|
+
report$1(ctx, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`);
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
if (ctx.reservedAttributes.has(name) && node.reserved !== true) {
|
|
1759
|
+
report$1(ctx, "reserved-attribute", `<${tag} ${name}> is reserved for the brand footer.`);
|
|
1760
|
+
continue;
|
|
1761
|
+
}
|
|
1762
|
+
if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
|
|
1763
|
+
const text = attributeText(ctx, tag, name, { type: "string" }, value);
|
|
1764
|
+
if (text !== void 0) emitted.push(` ${name}="${escapeAttribute$1(text)}"`);
|
|
1765
|
+
continue;
|
|
1766
|
+
}
|
|
1767
|
+
const globals = EMAIL_GLOBAL_ATTRIBUTES;
|
|
1768
|
+
const attribute = Object.hasOwn(globals, name) ? globals[name] : Object.hasOwn(spec.attributes, name) ? spec.attributes[name] : void 0;
|
|
1769
|
+
if (attribute === void 0) {
|
|
1770
|
+
report$1(ctx, "unsupported-attribute", `<${tag}> does not support the ${name} attribute.`);
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
present.add(name);
|
|
1774
|
+
const text = attributeText(ctx, tag, name, attribute, value);
|
|
1775
|
+
if (text !== void 0) emitted.push(` ${name}="${escapeAttribute$1(text)}"`);
|
|
1776
|
+
}
|
|
1777
|
+
for (const [name, attribute] of Object.entries(spec.attributes)) if (attribute.required === true && !present.has(name)) report$1(ctx, "missing-attribute", `<${tag}> requires the ${name} attribute.`);
|
|
1778
|
+
if (node.style !== void 0 && ctx.web === void 0) {
|
|
1779
|
+
const css = styleText(ctx, scope, node.style, depth + 1);
|
|
1780
|
+
if (css !== "") emitted.push(` style="${escapeAttribute$1(css)}"`);
|
|
1781
|
+
}
|
|
1782
|
+
if (node.style !== void 0 && ctx.web !== void 0) {
|
|
1783
|
+
const css = styleText(ctx, scope, node.style, depth + 1);
|
|
1784
|
+
if (css !== "") {
|
|
1785
|
+
let name = ctx.web.styles.get(css);
|
|
1786
|
+
if (name === void 0) {
|
|
1787
|
+
name = `sml-web-style-${ctx.web.styles.size}`;
|
|
1788
|
+
ctx.web.styles.set(css, name);
|
|
1789
|
+
}
|
|
1790
|
+
const classAt = emitted.findIndex((part) => part.startsWith(" class=\""));
|
|
1791
|
+
const combined = ` class="${escapeAttribute$1(classes === void 0 || classes === "" ? name : `${classes} ${name}`)}"`;
|
|
1792
|
+
if (classAt < 0) emitted.push(combined);
|
|
1793
|
+
else emitted[classAt] = combined;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return emitted.join("");
|
|
1797
|
+
};
|
|
1798
|
+
const checkContentModel$1 = (ctx, tag, spec, children) => {
|
|
1799
|
+
if (spec.content === "void") {
|
|
1800
|
+
if (children.length > 0) report$1(ctx, "void-element-children", `<${tag}> cannot have children.`);
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
if (spec.content === "text" || spec.content === "raw") {
|
|
1804
|
+
for (const child of children) if (child.t !== "text") report$1(ctx, "invalid-content", `<${tag}> accepts text only.`);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (spec.childElements === void 0) return;
|
|
1808
|
+
for (const child of children) {
|
|
1809
|
+
if (child.t === "raw") continue;
|
|
1810
|
+
if (child.t === "text") {
|
|
1811
|
+
if (child.v.trim() !== "") report$1(ctx, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`);
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
if (!spec.childElements.includes(child.tag)) report$1(ctx, "invalid-content", `<${tag}> accepts only ${spec.childElements.map((name) => `<${name}>`).join(", ")}; found <${child.tag}>.`);
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
const pushText = (out, value) => {
|
|
1818
|
+
if (value === null || value === void 0 || typeof value === "boolean") return;
|
|
1819
|
+
out.push({
|
|
1820
|
+
t: "text",
|
|
1821
|
+
v: textOf(value)
|
|
1822
|
+
});
|
|
1823
|
+
};
|
|
1824
|
+
const evalElement = (ctx, scope, node, depth, out) => {
|
|
1825
|
+
const tag = node.el;
|
|
1826
|
+
if (ctx.plain) {
|
|
1827
|
+
report$1(ctx, "unsupported-element", "A plain-text body cannot contain elements.");
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const spec = typeof tag === "string" ? ctx.web === void 0 ? emailElement(tag) : webElement(tag) : void 0;
|
|
1831
|
+
if (spec === void 0) {
|
|
1832
|
+
report$1(ctx, "unsupported-element", `<${String(tag)}> is not part of the supported email vocabulary.`);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (ctx.web !== void 0) {
|
|
1836
|
+
const hints = ctx.web.hints.get(node);
|
|
1837
|
+
if (hints !== void 0 && !ctx.web.hinted) {
|
|
1838
|
+
ctx.web.hinted = true;
|
|
1839
|
+
if (hints.title !== void 0) ctx.web.title = textOf(evalValue(ctx, scope, hints.title, depth + 1));
|
|
1840
|
+
if (hints.titleNodes !== void 0 && hints.titleNodes.length > 0) {
|
|
1841
|
+
const title = [];
|
|
1842
|
+
for (const part of hints.titleNodes) evalNode(ctx, scope, part, depth + 1, title);
|
|
1843
|
+
if (title.some((part) => part.t !== "text")) invalid("A page title contains text only.");
|
|
1844
|
+
ctx.web.title = title.map((part) => part.t === "text" ? part.v : "").join("");
|
|
1845
|
+
}
|
|
1846
|
+
if (hints.lang !== void 0) ctx.web.lang = textOf(evalValue(ctx, scope, hints.lang, depth + 1));
|
|
1847
|
+
const dir = hints.dir === void 0 ? void 0 : evalValue(ctx, scope, hints.dir, depth + 1);
|
|
1848
|
+
if (dir !== void 0 && dir !== "ltr" && dir !== "rtl" && dir !== "auto") invalid("Page direction must be ltr, rtl or auto.");
|
|
1849
|
+
if (dir === "ltr" || dir === "rtl" || dir === "auto") ctx.web.dir = dir;
|
|
1850
|
+
}
|
|
1851
|
+
if (spec.content === "raw" || [
|
|
1852
|
+
"html",
|
|
1853
|
+
"head",
|
|
1854
|
+
"body",
|
|
1855
|
+
"meta",
|
|
1856
|
+
"link",
|
|
1857
|
+
"title"
|
|
1858
|
+
].includes(tag)) return invalid("Web output is a body fragment without shell metadata or raw CSS.");
|
|
1859
|
+
}
|
|
1860
|
+
const children = [];
|
|
1861
|
+
for (const child of node.children ?? []) evalNode(ctx, scope, child, depth + 1, children);
|
|
1862
|
+
checkContentModel$1(ctx, tag, spec, children);
|
|
1863
|
+
if (spec.content === "raw") {
|
|
1864
|
+
const css = children.map((child) => child.t === "text" ? child.v : "").join("");
|
|
1865
|
+
if (/<\/|<!--/i.test(css)) {
|
|
1866
|
+
report$1(ctx, "unsafe-raw-text", `<${tag}> content cannot contain markup.`);
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const props = /* @__PURE__ */ new Map();
|
|
1871
|
+
const attrs = buildAttributes(ctx, scope, tag, spec, node, props, depth);
|
|
1872
|
+
out.push({
|
|
1873
|
+
t: "el",
|
|
1874
|
+
tag,
|
|
1875
|
+
content: spec.content,
|
|
1876
|
+
layout: spec.layout,
|
|
1877
|
+
attrs,
|
|
1878
|
+
props,
|
|
1879
|
+
children,
|
|
1880
|
+
src: node.src
|
|
1881
|
+
});
|
|
1882
|
+
};
|
|
1883
|
+
const evalRaw = (ctx, scope, node, depth, out) => {
|
|
1884
|
+
if (ctx.plain) {
|
|
1885
|
+
out.push({
|
|
1886
|
+
t: "text",
|
|
1887
|
+
v: node.text === void 0 ? "" : textOf(evalValue(ctx, scope, node.text, depth + 1))
|
|
1888
|
+
});
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const html = asArray(node.raw).map((part) => rawPart(ctx, scope, part, depth + 1)).join("");
|
|
1892
|
+
const text = node.text === void 0 ? "" : textOf(evalValue(ctx, scope, node.text, depth + 1));
|
|
1893
|
+
out.push({
|
|
1894
|
+
t: "raw",
|
|
1895
|
+
html,
|
|
1896
|
+
text
|
|
1897
|
+
});
|
|
1898
|
+
};
|
|
1899
|
+
const rawPart = (ctx, scope, part, depth) => {
|
|
1900
|
+
if (typeof part === "string") return part;
|
|
1901
|
+
const record = part;
|
|
1902
|
+
if (isRecord(record) && has(record, "attr")) return escapeAttribute$1(textOf(evalValue(ctx, scope, record.attr, depth + 1)));
|
|
1903
|
+
if (isRecord(record) && has(record, "text")) return escapeText$1(textOf(evalValue(ctx, scope, record.text, depth + 1)));
|
|
1904
|
+
return invalid("A raw part is a string, {attr} or {text}.");
|
|
1905
|
+
};
|
|
1906
|
+
const loopKey = (name) => name.startsWith("$") ? name : `$${name}`;
|
|
1907
|
+
/** The items that satisfy a `where` clause, each bound as the loop variable while it is tested. */
|
|
1908
|
+
const selectItems = (ctx, scope, items, as, where, depth) => {
|
|
1909
|
+
if (where === void 0) return items;
|
|
1910
|
+
const key = loopKey(as);
|
|
1911
|
+
return items.filter((item) => {
|
|
1912
|
+
if (++ctx.iterations > MAX_ITERATIONS) fail("render-limit", `A render iterates at most ${MAX_ITERATIONS} times.`);
|
|
1913
|
+
const inner = new Map(scope);
|
|
1914
|
+
inner.set(key, item);
|
|
1915
|
+
return evalPredicate(ctx, inner, where, depth + 1);
|
|
1916
|
+
});
|
|
1917
|
+
};
|
|
1918
|
+
const evalEach = (ctx, scope, node, depth, out) => {
|
|
1919
|
+
const source = evalValue(ctx, scope, node.each, depth + 1);
|
|
1920
|
+
if (!Array.isArray(source)) invalid("each needs an array.");
|
|
1921
|
+
const selected = selectItems(ctx, scope, source, String(node.as), node.where, depth);
|
|
1922
|
+
let items = selected;
|
|
1923
|
+
if (node.chunk !== void 0) {
|
|
1924
|
+
if (!Number.isInteger(node.chunk) || node.chunk < 1) invalid("chunk is a positive integer.");
|
|
1925
|
+
const slices = [];
|
|
1926
|
+
for (let at = 0; at < selected.length; at += node.chunk) slices.push(selected.slice(at, at + node.chunk));
|
|
1927
|
+
items = slices;
|
|
1928
|
+
}
|
|
1929
|
+
const itemKey = loopKey(String(node.as));
|
|
1930
|
+
const indexKey = node.index === void 0 ? void 0 : loopKey(String(node.index));
|
|
1931
|
+
for (const [index, item] of items.entries()) {
|
|
1932
|
+
if (++ctx.iterations > MAX_ITERATIONS) fail("render-limit", `A render iterates at most ${MAX_ITERATIONS} times.`);
|
|
1933
|
+
const inner = new Map(scope);
|
|
1934
|
+
inner.set(itemKey, item);
|
|
1935
|
+
if (indexKey !== void 0) inner.set(indexKey, index);
|
|
1936
|
+
for (const child of asArray(node.children)) evalNode(ctx, inner, child, depth + 1, out);
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
const evalNode = (ctx, scope, node, depth, out) => {
|
|
1940
|
+
checkDepth(depth);
|
|
1941
|
+
if (node === null || typeof node !== "object") {
|
|
1942
|
+
pushText(out, node);
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (!isRecord(node)) {
|
|
1946
|
+
invalid("An array cannot be rendered as a node.");
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
if (ctx.web !== void 0) {
|
|
1950
|
+
const css = ctx.web.stylesheets.get(node);
|
|
1951
|
+
if (css !== void 0) {
|
|
1952
|
+
ctx.web.authoredCss.push(webCss(ctx, css, depth, scope));
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
if (has(node, "el")) return evalElement(ctx, scope, node, depth, out);
|
|
1957
|
+
if (has(node, "each")) return evalEach(ctx, scope, node, depth, out);
|
|
1958
|
+
if (has(node, "raw")) return evalRaw(ctx, scope, node, depth, out);
|
|
1959
|
+
if (has(node, "preheader")) {
|
|
1960
|
+
if (!ctx.markerUsed && ctx.preheader !== void 0 && !ctx.plain) {
|
|
1961
|
+
ctx.markerUsed = true;
|
|
1962
|
+
out.push(preheaderBlock(ctx, ctx.preheader));
|
|
1963
|
+
}
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
if (has(node, "if") && Array.isArray(node.then)) {
|
|
1967
|
+
const branch = evalPredicate(ctx, scope, node.if, depth + 1) ? node.then : node.else ?? [];
|
|
1968
|
+
for (const child of branch) evalNode(ctx, scope, child, depth + 1, out);
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
pushText(out, evalValue(ctx, scope, node, depth + 1));
|
|
1972
|
+
};
|
|
1973
|
+
const PREHEADER_STYLE = [
|
|
1974
|
+
["display", "none"],
|
|
1975
|
+
["overflow", "hidden"],
|
|
1976
|
+
["lineHeight", "1px"],
|
|
1977
|
+
["opacity", 0],
|
|
1978
|
+
["maxHeight", 0],
|
|
1979
|
+
["maxWidth", 0]
|
|
1980
|
+
];
|
|
1981
|
+
/** The hidden inbox-preview block: the text, then padding so the client cannot borrow body copy. */
|
|
1982
|
+
const preheaderBlock = (ctx, text) => {
|
|
1983
|
+
const children = [text];
|
|
1984
|
+
if (text.length < PREHEADER_TARGET_LENGTH$1) children.push({
|
|
1985
|
+
el: "div",
|
|
1986
|
+
attrs: { "data-samva-preheader-padding": true },
|
|
1987
|
+
children: [PREHEADER_PADDING$1.repeat(Math.ceil((PREHEADER_TARGET_LENGTH$1 - text.length) / 7))]
|
|
1988
|
+
});
|
|
1989
|
+
const out = [];
|
|
1990
|
+
evalElement(ctx, /* @__PURE__ */ new Map(), {
|
|
1991
|
+
el: "div",
|
|
1992
|
+
attrs: { "data-samva-preheader": text },
|
|
1993
|
+
style: PREHEADER_STYLE,
|
|
1994
|
+
children
|
|
1995
|
+
}, 0, out);
|
|
1996
|
+
return out[0] ?? {
|
|
1997
|
+
t: "text",
|
|
1998
|
+
v: ""
|
|
1999
|
+
};
|
|
2000
|
+
};
|
|
2001
|
+
/** Put the block first inside `<body>`, or return undefined when there is no body. */
|
|
2002
|
+
const insertPreheader = (nodes, block) => {
|
|
2003
|
+
for (const [index, node] of nodes.entries()) {
|
|
2004
|
+
if (node.t !== "el") continue;
|
|
2005
|
+
if (node.tag === "body") return nodes.with(index, {
|
|
2006
|
+
...node,
|
|
2007
|
+
children: [block, ...node.children]
|
|
2008
|
+
});
|
|
2009
|
+
const replaced = insertPreheader(node.children, block);
|
|
2010
|
+
if (replaced !== void 0) return nodes.with(index, {
|
|
2011
|
+
...node,
|
|
2012
|
+
children: replaced
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
};
|
|
2016
|
+
const serialize$1 = (node) => {
|
|
2017
|
+
switch (node.t) {
|
|
2018
|
+
case "text": return escapeText$1(node.v);
|
|
2019
|
+
case "raw": return node.html;
|
|
2020
|
+
case "el":
|
|
2021
|
+
if (node.content === "raw") {
|
|
2022
|
+
const css = node.children.map((child) => child.t === "text" ? child.v : "").join("");
|
|
2023
|
+
return css === "" ? "" : `<${node.tag}${node.attrs}>${css}</${node.tag}>`;
|
|
2024
|
+
}
|
|
2025
|
+
if (node.content === "void") return `<${node.tag}${node.attrs} />`;
|
|
2026
|
+
return `<${node.tag}${node.attrs}>${node.children.map(serialize$1).join("")}</${node.tag}>`;
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
/** Serialize while recording where each element lands, for the same document `serialize` writes. */
|
|
2030
|
+
const serializeWithPositions = (nodes, offset) => {
|
|
2031
|
+
const positions = [];
|
|
2032
|
+
let written = offset;
|
|
2033
|
+
const write = (node) => {
|
|
2034
|
+
switch (node.t) {
|
|
2035
|
+
case "text": {
|
|
2036
|
+
const out = escapeText$1(node.v);
|
|
2037
|
+
written += out.length;
|
|
2038
|
+
return out;
|
|
2039
|
+
}
|
|
2040
|
+
case "raw":
|
|
2041
|
+
written += node.html.length;
|
|
2042
|
+
return node.html;
|
|
2043
|
+
case "el": {
|
|
2044
|
+
const start = written;
|
|
2045
|
+
let out;
|
|
2046
|
+
if (node.content === "raw") {
|
|
2047
|
+
const css = node.children.map((child) => child.t === "text" ? child.v : "").join("");
|
|
2048
|
+
out = css === "" ? "" : `<${node.tag}${node.attrs}>${css}</${node.tag}>`;
|
|
2049
|
+
written += out.length;
|
|
2050
|
+
} else if (node.content === "void") {
|
|
2051
|
+
out = `<${node.tag}${node.attrs} />`;
|
|
2052
|
+
written += out.length;
|
|
2053
|
+
} else {
|
|
2054
|
+
const open = `<${node.tag}${node.attrs}>`;
|
|
2055
|
+
written += open.length;
|
|
2056
|
+
const inner = node.children.map(write).join("");
|
|
2057
|
+
const close = `</${node.tag}>`;
|
|
2058
|
+
written += close.length;
|
|
2059
|
+
out = `${open}${inner}${close}`;
|
|
2060
|
+
}
|
|
2061
|
+
positions.push({
|
|
2062
|
+
start,
|
|
2063
|
+
end: written,
|
|
2064
|
+
tag: node.tag,
|
|
2065
|
+
...node.src === void 0 ? {} : { src: node.src }
|
|
2066
|
+
});
|
|
2067
|
+
return out;
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
return {
|
|
2072
|
+
html: nodes.map(write).join(""),
|
|
2073
|
+
positions
|
|
2074
|
+
};
|
|
2075
|
+
};
|
|
2076
|
+
const PARAGRAPH = "\n\n";
|
|
2077
|
+
const LINE = "\n";
|
|
2078
|
+
const INVISIBLE = /||||||/g;
|
|
2079
|
+
const TABLE_SECTIONS = /* @__PURE__ */ new Set([
|
|
2080
|
+
"thead",
|
|
2081
|
+
"tbody",
|
|
2082
|
+
"tfoot"
|
|
2083
|
+
]);
|
|
2084
|
+
const isLayoutTable = (props) => props.get("role") === "presentation" || props.get("role") === "none";
|
|
2085
|
+
const wrap = (inner, separator) => {
|
|
2086
|
+
const trimmed = inner.replace(/^\s+/, "").replace(/\s+$/, "");
|
|
2087
|
+
return trimmed === "" ? "" : `${separator}${trimmed}${separator}`;
|
|
2088
|
+
};
|
|
2089
|
+
const collect = (nodes, keep, descend) => nodes.flatMap((child) => {
|
|
2090
|
+
if (child.t !== "el") return [];
|
|
2091
|
+
if (keep(child.tag)) return [child];
|
|
2092
|
+
return descend(child.tag) ? collect(child.children, keep, descend) : [];
|
|
2093
|
+
});
|
|
2094
|
+
const childText = (node, context) => node.children.map((child) => derive(child, context)).join("");
|
|
2095
|
+
const isDecorativeCell = (cell, context) => {
|
|
2096
|
+
const images = collect(cell.children, (tag) => tag === "img", () => true);
|
|
2097
|
+
return images.length > 0 && images.every((image) => image.props.get("alt") === "") && childText(cell, context).trim() === "";
|
|
2098
|
+
};
|
|
2099
|
+
const deriveList = (node, context) => {
|
|
2100
|
+
const indent = " ".repeat(context.listDepth);
|
|
2101
|
+
const start = Number(node.props.get("start") ?? 1);
|
|
2102
|
+
const first = Number.isFinite(start) ? start : 1;
|
|
2103
|
+
const nested = {
|
|
2104
|
+
...context,
|
|
2105
|
+
listDepth: context.listDepth + 1
|
|
2106
|
+
};
|
|
2107
|
+
const lines = collect(node.children, (tag) => tag === "li", () => false).map((item, index) => {
|
|
2108
|
+
const marker = node.tag === "ol" ? `${first + index}.` : "-";
|
|
2109
|
+
const continuation = `${indent}${" ".repeat(marker.length + 1)}`;
|
|
2110
|
+
const [head = "", ...rest] = derive(item, nested).replace(/^\s+/, "").replace(/\s+$/, "").replace(/\n{2,}/g, LINE).split("\n");
|
|
2111
|
+
return [`${indent}${marker} ${head}`, ...rest.map((line) => line.trim() === "" ? "" : /^\s/.test(line) ? line : `${continuation}${line}`)].join(LINE);
|
|
2112
|
+
});
|
|
2113
|
+
return wrap(lines.join(LINE), PARAGRAPH);
|
|
2114
|
+
};
|
|
2115
|
+
const deriveTable = (node, context) => {
|
|
2116
|
+
if (isLayoutTable(node.props)) return childText(node, context);
|
|
2117
|
+
const rows = collect(node.children, (tag) => tag === "tr", (tag) => TABLE_SECTIONS.has(tag)).map((row) => collect(row.children, (tag) => tag === "td" || tag === "th", () => false).filter((cell) => !isDecorativeCell(cell, context)).map((cell) => childText(cell, context).replace(/\s+/g, " ").trim()).join(" | "));
|
|
2118
|
+
return wrap(rows.join(LINE), PARAGRAPH);
|
|
2119
|
+
};
|
|
2120
|
+
const deriveElement = (node, context) => {
|
|
2121
|
+
switch (node.layout) {
|
|
2122
|
+
case "metadata": return "";
|
|
2123
|
+
case "document":
|
|
2124
|
+
case "inline":
|
|
2125
|
+
case "list-item": return childText(node, context);
|
|
2126
|
+
case "break": return LINE;
|
|
2127
|
+
case "rule": return `${PARAGRAPH}---${PARAGRAPH}`;
|
|
2128
|
+
case "image": {
|
|
2129
|
+
const alt = node.props.get("alt");
|
|
2130
|
+
const trimmed = typeof alt === "string" ? alt.trim() : "";
|
|
2131
|
+
return trimmed === "" ? "" : `[${trimmed}]`;
|
|
2132
|
+
}
|
|
2133
|
+
case "link": {
|
|
2134
|
+
const label = childText(node, context).replace(/\s+/g, " ").trim();
|
|
2135
|
+
const target = node.props.get("href");
|
|
2136
|
+
const href = typeof target === "string" ? target : "";
|
|
2137
|
+
if (href === "" || href === label) return label;
|
|
2138
|
+
return label === "" ? href : `${label} (${href})`;
|
|
2139
|
+
}
|
|
2140
|
+
case "list": return deriveList(node, context);
|
|
2141
|
+
case "table": return deriveTable(node, context);
|
|
2142
|
+
case "row": return wrap(collect(node.children, (tag) => tag === "td" || tag === "th", () => false).map((cell) => childText(cell, context).replace(/^\s+|\s+$/g, "")).filter((cell) => cell !== "").join(LINE), LINE);
|
|
2143
|
+
case "table-section":
|
|
2144
|
+
case "cell": return wrap(childText(node, context), LINE);
|
|
2145
|
+
case "preformatted": return wrap(childText(node, {
|
|
2146
|
+
...context,
|
|
2147
|
+
preformatted: true
|
|
2148
|
+
}), PARAGRAPH);
|
|
2149
|
+
case "block": return wrap(childText(node, context), PARAGRAPH);
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
const derive = (node, context) => {
|
|
2153
|
+
switch (node.t) {
|
|
2154
|
+
case "text": return context.preformatted ? node.v : node.v.replace(INVISIBLE, "").replace(/[^\S\n]+/g, " ");
|
|
2155
|
+
case "raw": return node.text;
|
|
2156
|
+
case "el": return deriveElement(node, context);
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
const normalize = (value) => value.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/[^\S\n]+$/, "")).join("\n").replace(/\n{3,}/g, PARAGRAPH).trim();
|
|
2160
|
+
const createContext = (template, input, options, plain) => {
|
|
2161
|
+
if (template.sml !== 2) fail("unsupported-version", `The template was compiled as version ${String(template.sml)}; this renderer reads version 2.`);
|
|
2162
|
+
const brandPlugin = options.brandPlugin ?? samvaBrandPlugin;
|
|
2163
|
+
return {
|
|
2164
|
+
input,
|
|
2165
|
+
locale: options.locale ?? template.locale,
|
|
2166
|
+
timeZone: options.timeZone,
|
|
2167
|
+
plain,
|
|
2168
|
+
brandPlugin,
|
|
2169
|
+
reservedAttributes: reservedBrandAttributes(brandPlugin),
|
|
2170
|
+
diagnostics: [],
|
|
2171
|
+
iterations: 0,
|
|
2172
|
+
fmt: void 0,
|
|
2173
|
+
preheader: void 0,
|
|
2174
|
+
markerUsed: false
|
|
2175
|
+
};
|
|
2176
|
+
};
|
|
2177
|
+
const settle = (ctx) => {
|
|
2178
|
+
if (ctx.diagnostics.length > 0) throw new RenderError({ diagnostics: ctx.diagnostics });
|
|
2179
|
+
};
|
|
2180
|
+
/**
|
|
2181
|
+
* Render the email channel against one validated input. `positions` also returns where each
|
|
2182
|
+
* element landed in the HTML, which compatibility findings and editor selections map back to source.
|
|
2183
|
+
*/
|
|
2184
|
+
const renderEmailBody = (template, input, options = {}) => {
|
|
2185
|
+
const ctx = createContext(template, input, options, false);
|
|
2186
|
+
const email = template.email;
|
|
2187
|
+
if (email === void 0) return fail("missing-channel", "The template has no email channel.");
|
|
2188
|
+
const empty = /* @__PURE__ */ new Map();
|
|
2189
|
+
const subject = textOf(evalValue(ctx, empty, email.subject, 0));
|
|
2190
|
+
if (/[\r\n\u0000]/.test(subject)) fail("invalid-subject", "Invalid email subject: it must be one line of text.");
|
|
2191
|
+
if (email.preheader !== void 0) {
|
|
2192
|
+
const value = evalValue(ctx, empty, email.preheader, 0);
|
|
2193
|
+
if (value !== void 0 && value !== null) ctx.preheader = textOf(value);
|
|
2194
|
+
}
|
|
2195
|
+
const preheader = ctx.preheader;
|
|
2196
|
+
let tree = [];
|
|
2197
|
+
const nodes = [];
|
|
2198
|
+
validateEmailIr(email.body, email.presentation, (message, code) => fail(code ?? "invalid-value", message));
|
|
2199
|
+
const expanded = lowerEmailNodes([email.body], email.presentation?.brandPlugin ?? ctx.brandPlugin, (diagnostic) => {
|
|
2200
|
+
if (diagnostic.severity === "error") report$1(ctx, diagnostic.code, diagnostic.message);
|
|
2201
|
+
});
|
|
2202
|
+
const lowered = email.presentation === void 0 ? expanded : applyEmailPresentation(expanded, email.presentation);
|
|
2203
|
+
for (const node of lowered) evalNode(ctx, empty, node, 0, nodes);
|
|
2204
|
+
tree = nodes;
|
|
2205
|
+
if (preheader !== void 0 && !ctx.markerUsed) {
|
|
2206
|
+
const block = preheaderBlock(ctx, preheader);
|
|
2207
|
+
tree = insertPreheader(tree, block) ?? [block, ...tree];
|
|
2208
|
+
}
|
|
2209
|
+
settle(ctx);
|
|
2210
|
+
const text = normalize(tree.map((node) => derive(node, {
|
|
2211
|
+
preformatted: false,
|
|
2212
|
+
listDepth: 0
|
|
2213
|
+
})).join(""));
|
|
2214
|
+
if (options.positions === true) {
|
|
2215
|
+
const prefix = serializeWithPositions(tree, 0).html.startsWith("<html") ? EMAIL_DOCTYPE : "";
|
|
2216
|
+
const shifted = serializeWithPositions(tree, prefix.length);
|
|
2217
|
+
return {
|
|
2218
|
+
subject,
|
|
2219
|
+
preheader,
|
|
2220
|
+
html: `${prefix}${shifted.html}`,
|
|
2221
|
+
text,
|
|
2222
|
+
positions: shifted.positions
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
const serialized = tree.map(serialize$1).join("");
|
|
2226
|
+
return {
|
|
2227
|
+
subject,
|
|
2228
|
+
preheader,
|
|
2229
|
+
html: serialized.startsWith("<html") ? `${EMAIL_DOCTYPE}${serialized}` : serialized,
|
|
2230
|
+
text
|
|
2231
|
+
};
|
|
2232
|
+
};
|
|
2233
|
+
/** Render malformed artifacts through the same typed boundary as unsafe bound values. */
|
|
2234
|
+
const renderEmail = (template, input, options = {}) => {
|
|
2235
|
+
try {
|
|
2236
|
+
return renderEmailBody(template, input, options);
|
|
2237
|
+
} catch (error) {
|
|
2238
|
+
if (RenderError.is(error)) throw error;
|
|
2239
|
+
return fail("invalid-ir", "Malformed compiled email.");
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
/** Serialize parsed CSS through the same declaration and URL boundary as bound element styles. */
|
|
2243
|
+
const webCss = (ctx, rules, depth = 0, scope = /* @__PURE__ */ new Map()) => {
|
|
2244
|
+
checkDepth(depth);
|
|
2245
|
+
if (!Array.isArray(rules)) invalid("Web CSS rules must be an array.");
|
|
2246
|
+
return rules.map((rule) => {
|
|
2247
|
+
if (!isRecord(rule) || rule.selector === void 0 === (rule.at === void 0)) invalid("A web CSS rule has one selector or at-rule.");
|
|
2248
|
+
const header = rule.selector ?? rule.at;
|
|
2249
|
+
if (typeof header !== "string" || header === "" || /[{};\u0000-\u001f\u007f]|<[/!]|\/\*|\*\//.test(header) || /\\$/.test(header)) return invalid("Unsafe web CSS rule header.");
|
|
2250
|
+
if (rule.selector !== void 0 && header.includes("@")) invalid("A CSS selector cannot contain an at-rule.");
|
|
2251
|
+
if (rule.at !== void 0 && !/^(?:layer [\w.,-]+|(?:media|supports|container) [^{};\\]+|font-face|(?:keyframes|counter-style) [\w-]+|page(?: [\w :(),-]+)?)$/.test(header)) invalid("Unsupported web CSS at-rule.");
|
|
2252
|
+
const entries = rule.style ?? [];
|
|
2253
|
+
if (!Array.isArray(entries) || entries.some((entry) => !Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string")) invalid("Web CSS declarations are property and value pairs.");
|
|
2254
|
+
const css = declarations$1(ctx, scope, entries, depth + 1);
|
|
2255
|
+
const children = rule.children === void 0 ? "" : webCss(ctx, rule.children, depth + 1, scope);
|
|
2256
|
+
const prefix = rule.at === void 0 ? header : `@${header}`;
|
|
2257
|
+
if (rule.at?.startsWith("layer ") && rule.children === void 0 && rule.style === void 0) return `${prefix};`;
|
|
2258
|
+
return `${prefix}{${css}${css === "" || children === "" ? "" : ";"}${children}}`;
|
|
2259
|
+
}).join("");
|
|
2260
|
+
};
|
|
2261
|
+
const renderWeb = (template, input, options) => {
|
|
2262
|
+
try {
|
|
2263
|
+
const initial = createContext(template, input, options, false);
|
|
2264
|
+
const email = template.email;
|
|
2265
|
+
if (email === void 0) return fail("missing-channel", "The web target requires an email channel.");
|
|
2266
|
+
validateEmailIr(email.body, void 0, (message, code) => fail(code ?? "invalid-value", message));
|
|
2267
|
+
validateEmailIr({ lit: email.web }, void 0, (message, code) => fail(code ?? "invalid-value", message));
|
|
2268
|
+
if (email.web !== void 0 && (!isRecord(email.web) || !Array.isArray(email.web.rules))) invalid("Invalid web presentation plan.");
|
|
2269
|
+
const plugin = options.brandPlugin ?? email.presentation?.brandPlugin ?? initial.brandPlugin;
|
|
2270
|
+
if (!isRecord(plugin) || typeof plugin.specifier !== "string" || ![
|
|
2271
|
+
plugin.footerAttribute,
|
|
2272
|
+
plugin.unsubscribeRowAttribute,
|
|
2273
|
+
plugin.noTrackAttribute
|
|
2274
|
+
].every((name) => typeof name === "string" && /^data-[a-z0-9-]+$/.test(name)) || typeof plugin.unsubscribeUrlPlaceholder !== "string" || CONTROL_CHARACTERS$1.test(plugin.unsubscribeUrlPlaceholder) || /^[a-z][a-z0-9+.-]*:/i.test(plugin.unsubscribeUrlPlaceholder) && !isSafeUrl$1(plugin.unsubscribeUrlPlaceholder)) invalid("Invalid web brand plugin.");
|
|
2275
|
+
const lowered = lowerWebNodes([email.body], plugin, invalid, email.web);
|
|
2276
|
+
const web = {
|
|
2277
|
+
styles: /* @__PURE__ */ new Map(),
|
|
2278
|
+
hints: lowered.headHints,
|
|
2279
|
+
stylesheets: lowered.stylesheets,
|
|
2280
|
+
gridTracks: lowered.gridTracks,
|
|
2281
|
+
authoredCss: [],
|
|
2282
|
+
hinted: false
|
|
2283
|
+
};
|
|
2284
|
+
const webCtx = {
|
|
2285
|
+
...initial,
|
|
2286
|
+
web,
|
|
2287
|
+
brandPlugin: plugin,
|
|
2288
|
+
reservedAttributes: reservedBrandAttributes(plugin)
|
|
2289
|
+
};
|
|
2290
|
+
const nodes = [];
|
|
2291
|
+
for (const node of lowered.nodes) evalNode(webCtx, /* @__PURE__ */ new Map(), node, 0, nodes);
|
|
2292
|
+
const title = web.title ?? textOf(evalValue(webCtx, /* @__PURE__ */ new Map(), email.subject, 0));
|
|
2293
|
+
const componentLayer = email.web?.componentLayer;
|
|
2294
|
+
const specificity = email.web?.inlineSpecificity ?? 1;
|
|
2295
|
+
if (componentLayer !== void 0 && (!Number.isSafeInteger(componentLayer) || componentLayer < 0)) invalid("Invalid web component layer.");
|
|
2296
|
+
if (!Number.isInteger(specificity) || specificity < 1 || specificity > 1e5) invalid("Invalid web style specificity.");
|
|
2297
|
+
const projectRules = email.web?.rules ?? [];
|
|
2298
|
+
const baseRules = componentLayer === void 0 ? WEB_BASE_STYLES : WEB_BASE_STYLES.map((rule) => ({
|
|
2299
|
+
...rule,
|
|
2300
|
+
at: `layer sml-web-${componentLayer}`
|
|
2301
|
+
}));
|
|
2302
|
+
const css = webCss(webCtx, [
|
|
2303
|
+
...projectRules.filter((rule) => rule.at?.startsWith("layer ") && rule.children === void 0 && rule.style === void 0),
|
|
2304
|
+
...baseRules,
|
|
2305
|
+
...projectRules.filter((rule) => !(rule.at?.startsWith("layer ") && rule.children === void 0 && rule.style === void 0))
|
|
2306
|
+
]);
|
|
2307
|
+
const ids = "#sml-web-inline".repeat(specificity);
|
|
2308
|
+
const styles = `${css}${web.authoredCss.join("")}${[...web.styles].map(([style, name]) => `:is(${ids},.${name}){${style}}`).join("")}`;
|
|
2309
|
+
settle(webCtx);
|
|
2310
|
+
const serialized = serializeWithPositions(nodes, 0);
|
|
2311
|
+
return {
|
|
2312
|
+
html: serialized.html,
|
|
2313
|
+
positions: serialized.positions,
|
|
2314
|
+
headHints: {
|
|
2315
|
+
title,
|
|
2316
|
+
...web.lang === void 0 ? {} : { lang: web.lang },
|
|
2317
|
+
...web.dir === void 0 ? {} : { dir: web.dir },
|
|
2318
|
+
styles
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
} catch (error) {
|
|
2322
|
+
if (RenderError.is(error)) throw error;
|
|
2323
|
+
return fail("invalid-ir", "Malformed compiled web document.");
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
const plainText = (ctx, nodes) => {
|
|
2327
|
+
const out = [];
|
|
2328
|
+
for (const node of asArray(nodes)) evalNode(ctx, /* @__PURE__ */ new Map(), node, 0, out);
|
|
2329
|
+
return out.map((node) => node.t === "text" ? node.v : node.t === "raw" ? node.text : "").join("");
|
|
2330
|
+
};
|
|
2331
|
+
const renderSms = (template, input, options) => {
|
|
2332
|
+
const ctx = createContext(template, input, options, true);
|
|
2333
|
+
const sms = template.sms;
|
|
2334
|
+
if (sms === void 0) return fail("missing-channel", "The template has no SMS channel.");
|
|
2335
|
+
const text = plainText(ctx, sms.body);
|
|
2336
|
+
settle(ctx);
|
|
2337
|
+
return {
|
|
2338
|
+
text,
|
|
2339
|
+
segments: smsSegmentInfo(text)
|
|
2340
|
+
};
|
|
2341
|
+
};
|
|
2342
|
+
const resolveButtons = (ctx, nodes, out, depth) => {
|
|
2343
|
+
checkDepth(depth);
|
|
2344
|
+
const scope = /* @__PURE__ */ new Map();
|
|
2345
|
+
const value = (part) => textOf(evalValue(ctx, scope, part, 0));
|
|
2346
|
+
for (const node of asArray(nodes)) {
|
|
2347
|
+
if ("if" in node) {
|
|
2348
|
+
const branch = evalPredicate(ctx, scope, node.if, depth + 1) ? node.then : node.else ?? [];
|
|
2349
|
+
resolveButtons(ctx, branch, out, depth + 1);
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
switch (node.type) {
|
|
2353
|
+
case "quick-reply":
|
|
2354
|
+
out.push({
|
|
2355
|
+
type: "quick-reply",
|
|
2356
|
+
text: value(node.text)
|
|
2357
|
+
});
|
|
2358
|
+
break;
|
|
2359
|
+
case "url": {
|
|
2360
|
+
const url = value(node.url);
|
|
2361
|
+
if (!isSafeUrl$1(url)) invalid("A button URL must be an absolute https URL.");
|
|
2362
|
+
out.push({
|
|
2363
|
+
type: "url",
|
|
2364
|
+
text: value(node.text),
|
|
2365
|
+
url
|
|
2366
|
+
});
|
|
2367
|
+
break;
|
|
2368
|
+
}
|
|
2369
|
+
case "phone":
|
|
2370
|
+
out.push({
|
|
2371
|
+
type: "phone",
|
|
2372
|
+
text: value(node.text),
|
|
2373
|
+
phone: value(node.phone)
|
|
2374
|
+
});
|
|
2375
|
+
break;
|
|
2376
|
+
case "copy-code":
|
|
2377
|
+
out.push({
|
|
2378
|
+
type: "copy-code",
|
|
2379
|
+
code: value(node.code)
|
|
2380
|
+
});
|
|
2381
|
+
break;
|
|
2382
|
+
case "flow":
|
|
2383
|
+
out.push({
|
|
2384
|
+
type: "flow",
|
|
2385
|
+
text: value(node.text),
|
|
2386
|
+
flow: node.flow,
|
|
2387
|
+
action: node.action,
|
|
2388
|
+
...node.screen === void 0 ? {} : { screen: node.screen }
|
|
2389
|
+
});
|
|
2390
|
+
break;
|
|
2391
|
+
default: invalid("Unknown WhatsApp button.");
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
};
|
|
2395
|
+
/** The WhatsApp channel with every text rendered and conditional buttons resolved. */
|
|
2396
|
+
const renderWhatsApp = (template, input, options) => {
|
|
2397
|
+
const ctx = createContext(template, input, options, true);
|
|
2398
|
+
const whatsapp = template.whatsapp;
|
|
2399
|
+
if (whatsapp === void 0) return fail("missing-channel", "The template has no WhatsApp channel.");
|
|
2400
|
+
const header = whatsapp.header === void 0 ? void 0 : {
|
|
2401
|
+
type: whatsapp.header.type,
|
|
2402
|
+
text: plainText(ctx, whatsapp.header.content)
|
|
2403
|
+
};
|
|
2404
|
+
if (header !== void 0 && header.type !== "text" && !isSafeUrl$1(header.text)) invalid("A media header URL must be an absolute https URL.");
|
|
2405
|
+
const body = plainText(ctx, whatsapp.body);
|
|
2406
|
+
const footer = whatsapp.footer === void 0 ? void 0 : plainText(ctx, whatsapp.footer);
|
|
2407
|
+
const buttons = [];
|
|
2408
|
+
resolveButtons(ctx, whatsapp.buttons ?? [], buttons, 0);
|
|
2409
|
+
settle(ctx);
|
|
2410
|
+
return {
|
|
2411
|
+
language: whatsapp.language,
|
|
2412
|
+
category: whatsapp.category,
|
|
2413
|
+
...header === void 0 ? {} : { header },
|
|
2414
|
+
body,
|
|
2415
|
+
...footer === void 0 ? {} : { footer },
|
|
2416
|
+
buttons
|
|
2417
|
+
};
|
|
2418
|
+
};
|
|
2419
|
+
const TARGET_RENDERERS = {
|
|
2420
|
+
email: (template, input, options) => renderEmail(template, input, options),
|
|
2421
|
+
web: renderWeb,
|
|
2422
|
+
sms: renderSms,
|
|
2423
|
+
whatsapp: renderWhatsApp
|
|
2424
|
+
};
|
|
2425
|
+
/**
|
|
2426
|
+
* Render one target of a compiled template against one validated input. It throws only
|
|
2427
|
+
* `RenderError`: a template compiled by another version, a target the template does not support,
|
|
2428
|
+
* or input or markup the renderer refuses.
|
|
2429
|
+
*/
|
|
2430
|
+
const render = (template, input, options) => {
|
|
2431
|
+
if (!Object.hasOwn(TARGET_RENDERERS, options.target)) return fail("unsupported-target", "Unknown render target.");
|
|
2432
|
+
return TARGET_RENDERERS[options.target](template, input, options);
|
|
2433
|
+
};
|
|
2434
|
+
//#endregion
|
|
2435
|
+
//#region src/preview.ts
|
|
2436
|
+
/**
|
|
2437
|
+
* The attribute a preview stamps so a click in a rendered document resolves to exactly one
|
|
2438
|
+
* rendered element. A delivered message never carries it.
|
|
2439
|
+
*/
|
|
2440
|
+
const INSTANCE_PATH_ATTRIBUTE = "data-samva-instance";
|
|
2441
|
+
const locationKey = (origin) => `${origin.fileName}:${origin.lineNumber}:${origin.columnNumber}`;
|
|
2442
|
+
const originKey = (selection) => `${selection.tag}\u0000${selection.origins.map(locationKey).join("\0")}`;
|
|
2443
|
+
const parentOf = (instancePath) => {
|
|
2444
|
+
const cut = instancePath.lastIndexOf(".");
|
|
2445
|
+
return cut === -1 ? void 0 : instancePath.slice(0, cut);
|
|
2446
|
+
};
|
|
2447
|
+
/**
|
|
2448
|
+
* Turn one render's positions into selections, in document order. `occurrence` and `occurrences`
|
|
2449
|
+
* count the rendered elements that share an authored origin and tag.
|
|
2450
|
+
*/
|
|
2451
|
+
const emailSelections = (positions) => {
|
|
2452
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2453
|
+
for (const position of positions) {
|
|
2454
|
+
const key = originKey(position);
|
|
2455
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2456
|
+
}
|
|
2457
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2458
|
+
return [...positions].toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start).map((position) => {
|
|
2459
|
+
const key = originKey(position);
|
|
2460
|
+
const occurrence = (seen.get(key) ?? 0) + 1;
|
|
2461
|
+
seen.set(key, occurrence);
|
|
2462
|
+
const parentPath = parentOf(position.instancePath);
|
|
2463
|
+
return {
|
|
2464
|
+
instancePath: position.instancePath,
|
|
2465
|
+
tag: position.tag,
|
|
2466
|
+
origins: position.origins,
|
|
2467
|
+
authored: position.authored,
|
|
2468
|
+
occurrence,
|
|
2469
|
+
occurrences: counts.get(key) ?? 1,
|
|
2470
|
+
...parentPath === void 0 ? {} : { parentPath },
|
|
2471
|
+
start: position.start,
|
|
2472
|
+
end: position.end
|
|
2473
|
+
};
|
|
2474
|
+
});
|
|
2475
|
+
};
|
|
2476
|
+
/**
|
|
2477
|
+
* Render one fixture with an instance path stamped on every element, and the selection each path
|
|
2478
|
+
* resolves to. The selections come from the spans the compiler recorded, so a click on the canvas
|
|
2479
|
+
* lands on the exact node of the source that produced it.
|
|
2480
|
+
*/
|
|
2481
|
+
const previewEmail = (template, input, options = {}) => {
|
|
2482
|
+
const rendered = renderEmail(template, input, {
|
|
2483
|
+
...options,
|
|
2484
|
+
positions: true
|
|
2485
|
+
});
|
|
2486
|
+
const sources = template.sources ?? [];
|
|
2487
|
+
const ordered = (rendered.positions ?? []).map((position) => {
|
|
2488
|
+
const src = position.src;
|
|
2489
|
+
const origins = src === void 0 ? [] : [{
|
|
2490
|
+
fileName: sources[src[2] ?? 0] ?? "",
|
|
2491
|
+
lineNumber: src[0],
|
|
2492
|
+
columnNumber: src[1]
|
|
2493
|
+
}];
|
|
2494
|
+
return {
|
|
2495
|
+
start: position.start,
|
|
2496
|
+
end: position.end,
|
|
2497
|
+
tag: position.tag,
|
|
2498
|
+
src: src === void 0 ? void 0 : src.join(":"),
|
|
2499
|
+
origins,
|
|
2500
|
+
path: "",
|
|
2501
|
+
parent: void 0
|
|
2502
|
+
};
|
|
2503
|
+
}).toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start);
|
|
2504
|
+
const open = [];
|
|
2505
|
+
const childCount = /* @__PURE__ */ new Map();
|
|
2506
|
+
for (const element of ordered) {
|
|
2507
|
+
while (open.length > 0 && open[open.length - 1].end <= element.start) open.pop();
|
|
2508
|
+
const parent = open[open.length - 1];
|
|
2509
|
+
const index = childCount.get(parent) ?? 0;
|
|
2510
|
+
childCount.set(parent, index + 1);
|
|
2511
|
+
element.parent = parent;
|
|
2512
|
+
element.path = parent === void 0 ? String(index) : `${parent.path}.${index}`;
|
|
2513
|
+
open.push(element);
|
|
2514
|
+
}
|
|
2515
|
+
const inserts = ordered.map((element) => ({
|
|
2516
|
+
at: element.start + 1 + element.tag.length,
|
|
2517
|
+
text: ` ${INSTANCE_PATH_ATTRIBUTE}="${element.path}"`
|
|
2518
|
+
}));
|
|
2519
|
+
inserts.sort((left, right) => left.at - right.at);
|
|
2520
|
+
let html = "";
|
|
2521
|
+
let cursor = 0;
|
|
2522
|
+
for (const insert of inserts) {
|
|
2523
|
+
html += rendered.html.slice(cursor, insert.at) + insert.text;
|
|
2524
|
+
cursor = insert.at;
|
|
2525
|
+
}
|
|
2526
|
+
html += rendered.html.slice(cursor);
|
|
2527
|
+
const shift = (offset) => {
|
|
2528
|
+
let moved = 0;
|
|
2529
|
+
for (const insert of inserts) {
|
|
2530
|
+
if (insert.at > offset) break;
|
|
2531
|
+
moved += insert.text.length;
|
|
2532
|
+
}
|
|
2533
|
+
return offset + moved;
|
|
2534
|
+
};
|
|
2535
|
+
const positions = ordered.map((element) => ({
|
|
2536
|
+
start: shift(element.start),
|
|
2537
|
+
end: shift(element.end),
|
|
2538
|
+
origins: element.origins,
|
|
2539
|
+
instancePath: element.path,
|
|
2540
|
+
tag: element.tag,
|
|
2541
|
+
authored: element.src !== void 0 && (element.parent === void 0 || element.parent.src !== element.src)
|
|
2542
|
+
}));
|
|
2543
|
+
const selections = emailSelections(positions);
|
|
2544
|
+
return {
|
|
2545
|
+
subject: rendered.subject,
|
|
2546
|
+
preheader: rendered.preheader,
|
|
2547
|
+
html,
|
|
2548
|
+
text: rendered.text,
|
|
2549
|
+
selections
|
|
2550
|
+
};
|
|
2551
|
+
};
|
|
2552
|
+
/** Look selections up by the instance path a rendered document carries. */
|
|
2553
|
+
const emailSelectionIndex = (selections) => new Map(selections.map((selection) => [selection.instancePath, selection]));
|
|
2554
|
+
/**
|
|
2555
|
+
* The authoring element a selection edits. For an authored element that is the element itself; for
|
|
2556
|
+
* a generated wrapper it is the primitive that asked for it. Undefined only where the compiler
|
|
2557
|
+
* generated markup with no authoring behind it, which nothing can edit in place.
|
|
2558
|
+
*/
|
|
2559
|
+
const emailAuthoringTarget = (selection) => selection.origins[0];
|
|
2560
|
+
/** Root-first ancestry of a selection, itself last. */
|
|
2561
|
+
const emailSelectionAncestry = (index, instancePath) => {
|
|
2562
|
+
const chain = [];
|
|
2563
|
+
let current = index.get(instancePath);
|
|
2564
|
+
while (current !== void 0) {
|
|
2565
|
+
chain.unshift(current);
|
|
2566
|
+
current = current.parentPath === void 0 ? void 0 : index.get(current.parentPath);
|
|
2567
|
+
}
|
|
2568
|
+
return chain;
|
|
2569
|
+
};
|
|
2570
|
+
/**
|
|
2571
|
+
* Every rendered instance produced by the same authored element, in document order. One entry
|
|
2572
|
+
* means it rendered once; several mean it repeated.
|
|
2573
|
+
*/
|
|
2574
|
+
const emailSelectionSiblings = (selections, selection) => {
|
|
2575
|
+
const key = originKey(selection);
|
|
2576
|
+
return selections.filter((candidate) => originKey(candidate) === key);
|
|
2577
|
+
};
|
|
2578
|
+
/**
|
|
2579
|
+
* Every rendered instance of the same authored element. A partial edited at its definition changes
|
|
2580
|
+
* all of these, which is the scope a control has to name before it applies.
|
|
2581
|
+
*/
|
|
2582
|
+
const emailDefinitionInstances = (selections, selection) => {
|
|
2583
|
+
const authoring = selection.origins[0];
|
|
2584
|
+
const key = `${selection.tag}\u0000${authoring === void 0 ? "" : locationKey(authoring)}`;
|
|
2585
|
+
return selections.filter((candidate) => {
|
|
2586
|
+
const origin = candidate.origins[0];
|
|
2587
|
+
return `${candidate.tag}\u0000${origin === void 0 ? "" : locationKey(origin)}` === key;
|
|
2588
|
+
});
|
|
2589
|
+
};
|
|
2590
|
+
//#endregion
|
|
2591
|
+
//#region src/email/html.ts
|
|
2592
|
+
/** Escape a value for element content. */
|
|
2593
|
+
const escapeText = (value) => value.replace(/[&<>]/g, (character) => ({
|
|
2594
|
+
"&": "&",
|
|
2595
|
+
"<": "<",
|
|
2596
|
+
">": ">"
|
|
2597
|
+
})[character] ?? character);
|
|
2598
|
+
/** Escape a value for a double-quoted attribute. */
|
|
2599
|
+
const escapeAttribute = (value) => value.replace(/[&<>"']/g, (character) => ({
|
|
2600
|
+
"&": "&",
|
|
2601
|
+
"<": "<",
|
|
2602
|
+
">": ">",
|
|
2603
|
+
"\"": """,
|
|
2604
|
+
"'": "'"
|
|
2605
|
+
})[character] ?? character);
|
|
2606
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
2607
|
+
const SAFE_URL_SCHEME = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
|
|
2608
|
+
const RELATIVE_URL = /^(\.{1,2}\/|\/(?!\/))/;
|
|
2609
|
+
/**
|
|
2610
|
+
* Only references an email client will follow safely; no `javascript:`, no
|
|
2611
|
+
* `data:`. A relative reference is legal only for an export: a file on disk can
|
|
2612
|
+
* resolve one against its own directory, and a delivered message cannot resolve
|
|
2613
|
+
* anything but an absolute URL.
|
|
2614
|
+
*/
|
|
2615
|
+
const isSafeUrl = (value, relative) => !/^[\u0000-\u0020]/.test(value) && !CONTROL_CHARACTERS.test(value) && !value.includes("\\") && (SAFE_URL_SCHEME.test(value) || relative && RELATIVE_URL.test(value));
|
|
2616
|
+
const CSS_PROPERTY = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
|
|
2617
|
+
const CSS_UNSAFE = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
|
|
2618
|
+
const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
|
|
2619
|
+
/** camelCase to kebab-case; already-kebab names pass through unchanged. */
|
|
2620
|
+
const kebab = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
|
|
2621
|
+
const UNITLESS = /* @__PURE__ */ new Set([
|
|
2622
|
+
"opacity",
|
|
2623
|
+
"zIndex",
|
|
2624
|
+
"fontWeight",
|
|
2625
|
+
"lineHeight",
|
|
2626
|
+
"order",
|
|
2627
|
+
"flexGrow",
|
|
2628
|
+
"flexShrink",
|
|
2629
|
+
"columnCount",
|
|
2630
|
+
"orphans",
|
|
2631
|
+
"widows",
|
|
2632
|
+
"zoom",
|
|
2633
|
+
"fillOpacity",
|
|
2634
|
+
"strokeOpacity"
|
|
2635
|
+
]);
|
|
2636
|
+
/** A bare number is pixels for every property that takes a length. */
|
|
2637
|
+
const declarationValue = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
|
|
2638
|
+
const report = (sink, code, message, origins, severity = "error") => {
|
|
2639
|
+
sink.diagnostics.push({
|
|
2640
|
+
code,
|
|
2641
|
+
severity,
|
|
2642
|
+
message,
|
|
2643
|
+
origins
|
|
2644
|
+
});
|
|
2645
|
+
};
|
|
2646
|
+
const declarations = (sink, style, origins) => {
|
|
2647
|
+
const parts = [];
|
|
2648
|
+
for (const [property, value] of Object.entries(style)) {
|
|
2649
|
+
if (value === void 0 || value === null) continue;
|
|
2650
|
+
if (!CSS_PROPERTY.test(property)) {
|
|
2651
|
+
report(sink, "unsupported-style-property", `Style property ${JSON.stringify(property)} is not a CSS property name; write it in camelCase (backgroundColor) or as a custom property (--brand).`, origins);
|
|
2652
|
+
continue;
|
|
2653
|
+
}
|
|
2654
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
2655
|
+
report(sink, "invalid-style-value", `Style ${property} must be a string or number, not ${typeof value}.`, origins);
|
|
2656
|
+
continue;
|
|
2657
|
+
}
|
|
2658
|
+
const text = declarationValue(property, value);
|
|
2659
|
+
if (CSS_UNSAFE.test(text) || CONTROL_CHARACTERS.test(text)) {
|
|
2660
|
+
report(sink, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration; remove ${JSON.stringify(text)}.`, origins);
|
|
2661
|
+
continue;
|
|
2662
|
+
}
|
|
2663
|
+
let unsafeUrl;
|
|
2664
|
+
for (const match of text.matchAll(CSS_URL)) if (!isSafeUrl(match[2] ?? "", sink.relativeUrls)) unsafeUrl = match[2] ?? "";
|
|
2665
|
+
if (unsafeUrl !== void 0) {
|
|
2666
|
+
report(sink, "unsafe-url", `Style ${property} points at ${JSON.stringify(unsafeUrl)}; email URLs must use https:, mailto:, tel:, cid: or a fragment${sink.relativeUrls ? ", or a relative path" : ""}.`, origins);
|
|
2667
|
+
continue;
|
|
2668
|
+
}
|
|
2669
|
+
parts.push(`${kebab(property)}:${text}`);
|
|
2670
|
+
}
|
|
2671
|
+
return parts.join(";");
|
|
2672
|
+
};
|
|
2673
|
+
const attributeValue = (sink, tag, name, spec, value, origins) => {
|
|
2674
|
+
if (value === void 0 || value === null || value === false) return void 0;
|
|
2675
|
+
if (value === true) return "";
|
|
2676
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
2677
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a string or number, not ${typeof value}.`, origins);
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
const text = String(value);
|
|
2681
|
+
if (CONTROL_CHARACTERS.test(text)) {
|
|
2682
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`, origins);
|
|
2683
|
+
return;
|
|
2684
|
+
}
|
|
2685
|
+
if (spec.values !== void 0 && !spec.values.includes(text)) {
|
|
2686
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`, origins);
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
if (spec.type === "number" && !/^-?\d+$/.test(text)) {
|
|
2690
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`, origins);
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
if (spec.type === "measure" && !/^\d+(\.\d+)?%?$/.test(text)) {
|
|
2694
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`, origins);
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
if (spec.type === "url" && !isSafeUrl(text, sink.relativeUrls)) {
|
|
2698
|
+
report(sink, "unsafe-url", sink.relativeUrls ? `<${tag} ${name}> must use https:, mailto:, tel:, cid:, a fragment, or a relative path; received ${JSON.stringify(text)}.` : `<${tag} ${name}> must use https:, mailto:, tel:, cid: or a fragment; received ${JSON.stringify(text)}. A relative path resolves only in an export, never in a delivered message.`, origins);
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
return text;
|
|
2702
|
+
};
|
|
2703
|
+
const attributes = (sink, tag, spec, props, origins) => {
|
|
2704
|
+
const emitted = [];
|
|
2705
|
+
const present = /* @__PURE__ */ new Set();
|
|
2706
|
+
let classes;
|
|
2707
|
+
const unsubscribeLink = tag === "a" && props[UNSUBSCRIBE_LINK] === true;
|
|
2708
|
+
const { unsubscribeUrlPlaceholder, noTrackAttribute } = sink.brandPlugin;
|
|
2709
|
+
for (const [key, value] of Object.entries(props)) {
|
|
2710
|
+
if (key === "key") continue;
|
|
2711
|
+
const name = key === "className" ? "class" : key;
|
|
2712
|
+
if (unsubscribeLink && name === "href" && value === unsubscribeUrlPlaceholder) {
|
|
2713
|
+
present.add(name);
|
|
2714
|
+
emitted.push(` href="${escapeAttribute(unsubscribeUrlPlaceholder)}" ${noTrackAttribute}`);
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
if (name === "class") {
|
|
2718
|
+
if (value === void 0 || value === null) continue;
|
|
2719
|
+
if (typeof value !== "string") {
|
|
2720
|
+
report(sink, "invalid-attribute-value", `<${tag} class> must be a string.`, origins);
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
if (classes !== void 0) {
|
|
2724
|
+
report(sink, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`, origins);
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
classes = value;
|
|
2728
|
+
emitted.push(` class="${escapeAttribute(value)}"`);
|
|
2729
|
+
continue;
|
|
2730
|
+
}
|
|
2731
|
+
if (name === "style") {
|
|
2732
|
+
if (value === void 0 || value === null) continue;
|
|
2733
|
+
if (!isObject(value) || Array.isArray(value)) {
|
|
2734
|
+
report(sink, "invalid-style", `<${tag} style> must be a property object, for example style={{ backgroundColor: "#fff" }}.`, origins);
|
|
2735
|
+
continue;
|
|
2736
|
+
}
|
|
2737
|
+
const css = declarations(sink, value, origins);
|
|
2738
|
+
if (css !== "") emitted.push(` style="${escapeAttribute(css)}"`);
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
if (/^on/i.test(name)) {
|
|
2742
|
+
report(sink, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`, origins);
|
|
2743
|
+
continue;
|
|
2744
|
+
}
|
|
2745
|
+
if (sink.reservedAttributes.has(name) && props[BRAND_FOOTER_MARKER] !== true) {
|
|
2746
|
+
report(sink, "reserved-attribute", `<${tag} ${name}> is written only by BrandFooter from ${sink.brandPlugin.specifier}; render BrandFooter instead of marking an element as the footer.`, origins);
|
|
2747
|
+
continue;
|
|
2748
|
+
}
|
|
2749
|
+
if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
|
|
2750
|
+
const text = attributeValue(sink, tag, name, { type: "string" }, value, origins);
|
|
2751
|
+
if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
2754
|
+
const globalAttributes = EMAIL_GLOBAL_ATTRIBUTES;
|
|
2755
|
+
const attribute = Object.hasOwn(globalAttributes, name) ? globalAttributes[name] : Object.hasOwn(spec.attributes, name) ? spec.attributes[name] : void 0;
|
|
2756
|
+
if (attribute === void 0) {
|
|
2757
|
+
const supported = [
|
|
2758
|
+
...Object.keys(EMAIL_GLOBAL_ATTRIBUTES),
|
|
2759
|
+
...Object.keys(spec.attributes),
|
|
2760
|
+
"class",
|
|
2761
|
+
"style"
|
|
2762
|
+
].sort().join(", ");
|
|
2763
|
+
report(sink, "unsupported-attribute", `<${tag}> does not support the ${name} attribute. Supported: ${supported}, plus data-* and aria-*.`, origins);
|
|
2764
|
+
continue;
|
|
2765
|
+
}
|
|
2766
|
+
present.add(name);
|
|
2767
|
+
const text = attributeValue(sink, tag, name, attribute, value, origins);
|
|
2768
|
+
if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
|
|
2769
|
+
}
|
|
2770
|
+
for (const [name, attribute] of Object.entries(spec.attributes)) if (attribute.required === true && !present.has(name)) report(sink, "missing-attribute", `<${tag}> requires the ${name} attribute.`, origins);
|
|
2771
|
+
return emitted.join("");
|
|
2772
|
+
};
|
|
2773
|
+
/** Fragments are transparent to a content model; raw compatibility markup is not content. */
|
|
2774
|
+
const contentChildren = (children) => children.flatMap((child) => child.type === "Fragment" ? contentChildren(child.children) : [child]);
|
|
2775
|
+
const checkContentModel = (sink, tag, spec, children, origins) => {
|
|
2776
|
+
if (spec.content === "void") {
|
|
2777
|
+
if (contentChildren(children).length > 0) report(sink, "void-element-children", `<${tag}> cannot have children.`, origins);
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
if (spec.content === "text" || spec.content === "raw") {
|
|
2781
|
+
for (const child of contentChildren(children)) if (child.type !== "Text") report(sink, "invalid-content", `<${tag}> accepts text only.`, child.type === "Element" ? child.origins : origins);
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
if (spec.childElements === void 0) return;
|
|
2785
|
+
for (const child of contentChildren(children)) {
|
|
2786
|
+
if (child.type === "Raw") continue;
|
|
2787
|
+
if (child.type === "Text") {
|
|
2788
|
+
if (child.value.trim() !== "") report(sink, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`, origins);
|
|
2789
|
+
continue;
|
|
2790
|
+
}
|
|
2791
|
+
if (child.type === "Element" && !spec.childElements.includes(child.tag)) report(sink, "invalid-content", `<${tag}> accepts only ${spec.childElements.map((name) => `<${name}>`).join(", ")}; found <${child.tag}>.`, child.origins);
|
|
2792
|
+
}
|
|
2793
|
+
};
|
|
2794
|
+
/** Every write goes through here so `sink.written` stays the emitted offset. */
|
|
2795
|
+
const emit = (sink, text) => {
|
|
2796
|
+
sink.written += text.length;
|
|
2797
|
+
return text;
|
|
2798
|
+
};
|
|
2799
|
+
const serialize = (sink, node, parentPath, counter) => {
|
|
2800
|
+
switch (node.type) {
|
|
2801
|
+
case "Text": return emit(sink, escapeText(node.value));
|
|
2802
|
+
case "Raw": return emit(sink, node.html);
|
|
2803
|
+
case "Fragment": return node.children.map((child) => serialize(sink, child, parentPath, counter)).join("");
|
|
2804
|
+
case "Element": {
|
|
2805
|
+
const start = sink.written;
|
|
2806
|
+
const index = counter.next++;
|
|
2807
|
+
const path = parentPath === "" ? String(index) : `${parentPath}.${index}`;
|
|
2808
|
+
const own = { next: 0 };
|
|
2809
|
+
const stamp = sink.instancePaths ? ` ${INSTANCE_PATH_ATTRIBUTE}="${path}"` : "";
|
|
2810
|
+
const spec = emailElement(node.tag);
|
|
2811
|
+
if (spec === void 0) {
|
|
2812
|
+
report(sink, "unsupported-element", `<${node.tag}> is not part of the supported email vocabulary. Supported elements: ${Object.keys(EMAIL_ELEMENTS).join(", ")}.`, node.origins);
|
|
2813
|
+
return "";
|
|
2814
|
+
}
|
|
2815
|
+
checkContentModel(sink, node.tag, spec, node.children, node.origins);
|
|
2816
|
+
if (spec.content === "raw") {
|
|
2817
|
+
const css = node.children.map((child) => child.type === "Text" ? child.value : "").join("");
|
|
2818
|
+
if (/<\/|<!--/i.test(css)) {
|
|
2819
|
+
report(sink, "unsafe-raw-text", `<${node.tag}> content cannot contain markup.`, node.origins);
|
|
2820
|
+
return "";
|
|
2821
|
+
}
|
|
2822
|
+
const rawAttrs = attributes(sink, node.tag, spec, node.props, node.origins);
|
|
2823
|
+
const raw = css === "" ? "" : `<${node.tag}${rawAttrs}${stamp}>${css}</${node.tag}>`;
|
|
2824
|
+
return record(sink, start, node, path, emit(sink, raw));
|
|
2825
|
+
}
|
|
2826
|
+
const attrs = attributes(sink, node.tag, spec, node.props, node.origins);
|
|
2827
|
+
if (spec.content === "void") return record(sink, start, node, path, emit(sink, `<${node.tag}${attrs}${stamp} />`));
|
|
2828
|
+
const open = emit(sink, `<${node.tag}${attrs}${stamp}>`);
|
|
2829
|
+
const children = node.children.map((child) => serialize(sink, child, path, own)).join("");
|
|
2830
|
+
const close = emit(sink, `</${node.tag}>`);
|
|
2831
|
+
return record(sink, start, node, path, `${open}${children}${close}`);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
};
|
|
2835
|
+
/** Record an element's emitted range once its content is written. */
|
|
2836
|
+
const record = (sink, start, node, instancePath, text) => {
|
|
2837
|
+
sink.positions.push({
|
|
2838
|
+
start,
|
|
2839
|
+
end: sink.written,
|
|
2840
|
+
origins: node.origins,
|
|
2841
|
+
instancePath,
|
|
2842
|
+
tag: node.tag,
|
|
2843
|
+
authored: node.authored
|
|
2844
|
+
});
|
|
2845
|
+
return text;
|
|
2846
|
+
};
|
|
2847
|
+
const serializationSink = (options) => {
|
|
2848
|
+
const brandPlugin = options.brandPlugin ?? samvaBrandPlugin;
|
|
2849
|
+
return {
|
|
2850
|
+
diagnostics: [],
|
|
2851
|
+
positions: [],
|
|
2852
|
+
written: 0,
|
|
2853
|
+
relativeUrls: options.relativeUrls === true,
|
|
2854
|
+
instancePaths: options.instancePaths === true,
|
|
2855
|
+
brandPlugin,
|
|
2856
|
+
reservedAttributes: reservedBrandAttributes(brandPlugin)
|
|
2857
|
+
};
|
|
2858
|
+
};
|
|
2859
|
+
/** Lower the derived tree to email HTML, reporting every unsupported construct with its origin. */
|
|
2860
|
+
const serializeEmailHtml = (node, options = {}) => {
|
|
2861
|
+
const sink = serializationSink(options);
|
|
2862
|
+
return {
|
|
2863
|
+
html: serialize(sink, node, "", { next: 0 }),
|
|
2864
|
+
diagnostics: sink.diagnostics,
|
|
2865
|
+
positions: sink.positions
|
|
2866
|
+
};
|
|
2867
|
+
};
|
|
2868
|
+
/** An authored style object as a declaration list, checked by the same rules as a serialized tree. */
|
|
2869
|
+
const serializeStyle = (style, origins) => {
|
|
2870
|
+
const sink = serializationSink({});
|
|
2871
|
+
return {
|
|
2872
|
+
css: declarations(sink, style, origins),
|
|
2873
|
+
diagnostics: sink.diagnostics
|
|
2874
|
+
};
|
|
2875
|
+
};
|
|
2876
|
+
/**
|
|
2877
|
+
* Check one static attribute value against its element's spec, as serialization does. Returns
|
|
2878
|
+
* the text that would be emitted, or `undefined` when the attribute is dropped or invalid.
|
|
2879
|
+
*/
|
|
2880
|
+
const checkAttribute = (tag, name, value, origins) => {
|
|
2881
|
+
const sink = serializationSink({});
|
|
2882
|
+
const element = emailElement(tag);
|
|
2883
|
+
const globals = EMAIL_GLOBAL_ATTRIBUTES;
|
|
2884
|
+
const attributeSpec = () => {
|
|
2885
|
+
if (/^(data|aria)-[a-z0-9-]+$/.test(name)) return { type: "string" };
|
|
2886
|
+
if (Object.hasOwn(globals, name)) return globals[name];
|
|
2887
|
+
if (element !== void 0 && Object.hasOwn(element.attributes, name)) return element.attributes[name];
|
|
2888
|
+
};
|
|
2889
|
+
const spec = attributeSpec();
|
|
2890
|
+
if (spec === void 0) return {
|
|
2891
|
+
text: void 0,
|
|
2892
|
+
diagnostics: sink.diagnostics
|
|
2893
|
+
};
|
|
2894
|
+
return {
|
|
2895
|
+
text: attributeValue(sink, tag, name, spec, value, origins),
|
|
2896
|
+
diagnostics: sink.diagnostics
|
|
2897
|
+
};
|
|
2898
|
+
};
|
|
2899
|
+
//#endregion
|
|
2900
|
+
//#region src/email/components.ts
|
|
2901
|
+
/** Marks the hidden preview block so the renderer can read it back and the text lane can keep it. */
|
|
2902
|
+
const PREHEADER_ATTRIBUTE = "data-samva-preheader";
|
|
2903
|
+
const PREHEADER_PADDING_ATTRIBUTE = "data-samva-preheader-padding";
|
|
2904
|
+
const PREHEADER_TARGET_LENGTH = 200;
|
|
2905
|
+
const PREHEADER_PADDING = "\xA0";
|
|
2906
|
+
const presentation = (table, row, rowProps = {}) => jsx("table", {
|
|
2907
|
+
role: "presentation",
|
|
2908
|
+
border: 0,
|
|
2909
|
+
cellpadding: 0,
|
|
2910
|
+
cellspacing: 0,
|
|
2911
|
+
...table,
|
|
2912
|
+
children: jsx("tbody", { children: jsx("tr", {
|
|
2913
|
+
...rowProps,
|
|
2914
|
+
children: row
|
|
2915
|
+
}) })
|
|
2916
|
+
});
|
|
2917
|
+
/** A primitive's own class, then the author's. */
|
|
2918
|
+
const classNames = (own, authored) => authored === void 0 || authored.trim() === "" ? own : `${own} ${authored}`;
|
|
2919
|
+
const optionalNumber = (found, component, name, value, bounds = {}) => {
|
|
2920
|
+
if (value === void 0) return void 0;
|
|
2921
|
+
if (typeof value === "number" && Number.isFinite(value) && (bounds.min === void 0 || value >= bounds.min)) return value;
|
|
2922
|
+
found.push({
|
|
2923
|
+
code: "invalid-number-prop",
|
|
2924
|
+
severity: "error",
|
|
2925
|
+
message: `<${component} ${name}> must be a finite number${bounds.min === void 0 ? "" : ` of at least ${bounds.min}`}.`
|
|
2926
|
+
});
|
|
2927
|
+
};
|
|
2928
|
+
/** The hidden inbox preview block. Padding characters stop the client borrowing body copy. */
|
|
2929
|
+
const preheaderNode = (text) => jsx("div", {
|
|
2930
|
+
[PREHEADER_ATTRIBUTE]: text,
|
|
2931
|
+
style: {
|
|
2932
|
+
display: "none",
|
|
2933
|
+
overflow: "hidden",
|
|
2934
|
+
lineHeight: "1px",
|
|
2935
|
+
opacity: 0,
|
|
2936
|
+
maxHeight: 0,
|
|
2937
|
+
maxWidth: 0
|
|
2938
|
+
},
|
|
2939
|
+
children: [text, text.length >= PREHEADER_TARGET_LENGTH ? null : jsx("div", {
|
|
2940
|
+
[PREHEADER_PADDING_ATTRIBUTE]: true,
|
|
2941
|
+
children: PREHEADER_PADDING.repeat(Math.ceil((PREHEADER_TARGET_LENGTH - text.length) / 7))
|
|
2942
|
+
})]
|
|
2943
|
+
});
|
|
2944
|
+
/**
|
|
2945
|
+
* Document shell: the head every target client needs, a body painted with the page color, and
|
|
2946
|
+
* the content cell. The content cell sets the theme's text color and body font
|
|
2947
|
+
* (`--color-foreground`, `--font-sans`) for everything inside it, and pads the content off the
|
|
2948
|
+
* screen edge (`gutter`).
|
|
2949
|
+
*/
|
|
2950
|
+
const Email = ({ className, lang = "en", dir = "ltr", title, backgroundColor = "var(--color-background)", gutter, style, children }) => {
|
|
2951
|
+
const found = [];
|
|
2952
|
+
const safeGutter = optionalNumber(found, "Email", "gutter", gutter, { min: 0 });
|
|
2953
|
+
const cellStyle = {
|
|
2954
|
+
...safeGutter === void 0 ? {} : {
|
|
2955
|
+
paddingLeft: `${safeGutter}px`,
|
|
2956
|
+
paddingRight: `${safeGutter}px`
|
|
2957
|
+
},
|
|
2958
|
+
...style
|
|
2959
|
+
};
|
|
2960
|
+
const document = jsx("html", {
|
|
2961
|
+
lang,
|
|
2962
|
+
dir,
|
|
2963
|
+
xmlns: "http://www.w3.org/1999/xhtml",
|
|
2964
|
+
"xmlns:o": "urn:schemas-microsoft-com:office:office",
|
|
2965
|
+
children: [jsx("head", { children: [
|
|
2966
|
+
jsx("meta", { charset: "utf-8" }),
|
|
2967
|
+
jsx("meta", {
|
|
2968
|
+
"http-equiv": "Content-Type",
|
|
2969
|
+
content: "text/html; charset=UTF-8"
|
|
2970
|
+
}),
|
|
2971
|
+
jsx("meta", {
|
|
2972
|
+
name: "viewport",
|
|
2973
|
+
content: "width=device-width, initial-scale=1"
|
|
2974
|
+
}),
|
|
2975
|
+
jsx("meta", { name: "x-apple-disable-message-reformatting" }),
|
|
2976
|
+
jsx("meta", {
|
|
2977
|
+
name: "color-scheme",
|
|
2978
|
+
content: "light dark"
|
|
2979
|
+
}),
|
|
2980
|
+
jsx("meta", {
|
|
2981
|
+
name: "supported-color-schemes",
|
|
2982
|
+
content: "light dark"
|
|
2983
|
+
}),
|
|
2984
|
+
title === void 0 ? null : jsx("title", { children: title }),
|
|
2985
|
+
rawMarkup("<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->")
|
|
2986
|
+
] }), jsx("body", {
|
|
2987
|
+
style: {
|
|
2988
|
+
margin: 0,
|
|
2989
|
+
padding: 0,
|
|
2990
|
+
width: "100%",
|
|
2991
|
+
backgroundColor
|
|
2992
|
+
},
|
|
2993
|
+
children: [presentation({
|
|
2994
|
+
align: "center",
|
|
2995
|
+
width: "100%",
|
|
2996
|
+
bgcolor: backgroundColor,
|
|
2997
|
+
style: {
|
|
2998
|
+
width: "100%",
|
|
2999
|
+
backgroundColor
|
|
3000
|
+
}
|
|
3001
|
+
}, jsx("td", {
|
|
3002
|
+
className: classNames(PRIMITIVE_CLASSES.email, className),
|
|
3003
|
+
...Object.keys(cellStyle).length === 0 ? {} : { style: cellStyle },
|
|
3004
|
+
children
|
|
3005
|
+
}))]
|
|
3006
|
+
})]
|
|
3007
|
+
});
|
|
3008
|
+
return withEmailDiagnostics(document, found);
|
|
3009
|
+
};
|
|
3010
|
+
/** A pixel width as `width: 100%` capped at it, so a fixed-width table still fits a phone. */
|
|
3011
|
+
const fluidWidth = (width) => {
|
|
3012
|
+
const pixels = typeof width === "number" ? width : /^\d+(?:\.\d+)?(?:px)?$/.test(width) ? width : void 0;
|
|
3013
|
+
if (pixels === void 0) return void 0;
|
|
3014
|
+
return {
|
|
3015
|
+
width: "100%",
|
|
3016
|
+
maxWidth: typeof pixels === "number" ? `${pixels}px` : `${Number.parseFloat(pixels)}px`
|
|
3017
|
+
};
|
|
3018
|
+
};
|
|
3019
|
+
/** A full-width band of content, as the single-cell table every client agrees on. */
|
|
3020
|
+
const Section = ({ className, align = "center", width = "100%", bgcolor, style, tableStyle, children }) => {
|
|
3021
|
+
const fluid = fluidWidth(width);
|
|
3022
|
+
const table = fluid === void 0 ? tableStyle : {
|
|
3023
|
+
...fluid,
|
|
3024
|
+
...tableStyle
|
|
3025
|
+
};
|
|
3026
|
+
return presentation({
|
|
3027
|
+
align,
|
|
3028
|
+
width,
|
|
3029
|
+
...bgcolor === void 0 ? {} : { bgcolor },
|
|
3030
|
+
...table === void 0 ? {} : { style: table }
|
|
3031
|
+
}, jsx("td", {
|
|
3032
|
+
className,
|
|
3033
|
+
...style === void 0 ? {} : { style },
|
|
3034
|
+
children
|
|
3035
|
+
}));
|
|
3036
|
+
};
|
|
3037
|
+
/** The equal share of a row that each of `count` cells takes, whole percent rounded down. */
|
|
3038
|
+
const equalColumnWidth = (count) => `${Math.floor(100 / count)}%`;
|
|
3039
|
+
/**
|
|
3040
|
+
* Give a `Column` that states no width its equal share, keeping the primitive's attribute order,
|
|
3041
|
+
* and the stacking class when its row stacks.
|
|
3042
|
+
*/
|
|
3043
|
+
const withColumnLayout = (node, width, stack) => {
|
|
3044
|
+
if (node.type === "Fragment") return EmailNode.Fragment({ children: node.children.map((child) => withColumnLayout(child, width, stack)) });
|
|
3045
|
+
if (node.type !== "Element" || node.tag !== "td") return node;
|
|
3046
|
+
const props = {};
|
|
3047
|
+
const sized = width === void 0 || node.props.width !== void 0;
|
|
3048
|
+
for (const [key, value] of Object.entries(node.props)) {
|
|
3049
|
+
if (key === "style" && !sized) props.width = width;
|
|
3050
|
+
props[key] = value;
|
|
3051
|
+
}
|
|
3052
|
+
if (!sized && props.width === void 0) props.width = width;
|
|
3053
|
+
if (stack) props.className = classNames(PRIMITIVE_CLASSES.stack, typeof props.className === "string" ? props.className : void 0);
|
|
3054
|
+
return EmailNode.Element({
|
|
3055
|
+
...node,
|
|
3056
|
+
props
|
|
3057
|
+
});
|
|
3058
|
+
};
|
|
3059
|
+
/** The cell between two `Column`s: `gap` wide in a row, `gap` tall once the row stacks. */
|
|
3060
|
+
const gapCell = (gap, stack) => jsx("td", {
|
|
3061
|
+
...stack ? { className: PRIMITIVE_CLASSES.stack } : {},
|
|
3062
|
+
width: gap,
|
|
3063
|
+
style: {
|
|
3064
|
+
width: `${gap}px`,
|
|
3065
|
+
...stack ? { height: `${gap}px` } : {},
|
|
3066
|
+
fontSize: "1px",
|
|
3067
|
+
lineHeight: "1px"
|
|
3068
|
+
},
|
|
3069
|
+
children: ""
|
|
3070
|
+
});
|
|
3071
|
+
/** Cells with a gap cell between each two that render something. */
|
|
3072
|
+
const withGaps = (cells, gap, stack) => {
|
|
3073
|
+
if (gap === void 0 || gap === 0) return cells;
|
|
3074
|
+
const output = [];
|
|
3075
|
+
for (const cell of cells) {
|
|
3076
|
+
if (cell.type === "Text" && cell.value.trim() === "") {
|
|
3077
|
+
output.push(cell);
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
if (output.some((placed) => placed.type !== "Text" || placed.value.trim() !== "")) output.push(gapCell(gap, stack));
|
|
3081
|
+
output.push(cell);
|
|
3082
|
+
}
|
|
3083
|
+
return output;
|
|
3084
|
+
};
|
|
3085
|
+
/**
|
|
3086
|
+
* A row of side-by-side `Column`s that stacks on a narrow viewport (`stack`). No flex or grid
|
|
3087
|
+
* conversion happens here. With `each` it repeats its cells over a list, `per` to a row, and
|
|
3088
|
+
* splits the row equally between cells that state no width.
|
|
3089
|
+
*/
|
|
3090
|
+
const Columns = ({ className, align = "center", width = "100%", style, each, per, stack = true, gap, children }) => {
|
|
3091
|
+
const table = {
|
|
3092
|
+
className,
|
|
3093
|
+
align,
|
|
3094
|
+
width,
|
|
3095
|
+
...style === void 0 ? {} : { style }
|
|
3096
|
+
};
|
|
3097
|
+
const found = [];
|
|
3098
|
+
const safeGap = optionalNumber(found, "Columns", "gap", gap, { min: 0 });
|
|
3099
|
+
if (each === void 0 || typeof children !== "function") return withEmailDiagnostics(presentation(table, withGaps(normalizeChildren(children).map((cell) => withColumnLayout(cell, void 0, stack)), safeGap, stack), { style: { width: "100%" } }), found);
|
|
3100
|
+
if (per !== void 0 && (!Number.isInteger(per) || per < 1)) found.push({
|
|
3101
|
+
code: "invalid-columns-per",
|
|
3102
|
+
severity: "error",
|
|
3103
|
+
message: "<Columns per> must be a positive integer."
|
|
3104
|
+
});
|
|
3105
|
+
const size = per !== void 0 && Number.isInteger(per) && per >= 1 ? per : void 0;
|
|
3106
|
+
const chunks = [];
|
|
3107
|
+
if (size === void 0) chunks.push(each);
|
|
3108
|
+
else for (let at = 0; at < each.length; at += size) chunks.push(each.slice(at, at + size));
|
|
3109
|
+
const share = equalColumnWidth(size ?? Math.max(each.length, 1));
|
|
3110
|
+
const rows = jsx(Fragment, { children: chunks.map((chunk) => presentation(table, withGaps(chunk.flatMap((item, index) => normalizeChildren(children(item, index)).map((cell) => withColumnLayout(cell, share, stack))), safeGap, stack), { style: { width: "100%" } })) });
|
|
3111
|
+
return withEmailDiagnostics(rows, found);
|
|
3112
|
+
};
|
|
3113
|
+
/** One cell of a `Columns` row. */
|
|
3114
|
+
const Column = ({ className, align, valign, width, style, children }) => jsx("td", {
|
|
3115
|
+
className,
|
|
3116
|
+
...align === void 0 ? {} : { align },
|
|
3117
|
+
...valign === void 0 ? {} : { valign },
|
|
3118
|
+
...width === void 0 ? {} : { width },
|
|
3119
|
+
...style === void 0 ? {} : { style },
|
|
3120
|
+
children
|
|
3121
|
+
});
|
|
3122
|
+
/** Average advance of a bold sans-serif glyph, as a fraction of the font size. */
|
|
3123
|
+
const BOLD_GLYPH_ADVANCE = .6;
|
|
3124
|
+
const invalidButtonNumber = (found, name) => {
|
|
3125
|
+
found.push({
|
|
3126
|
+
code: "invalid-button-number",
|
|
3127
|
+
severity: "error",
|
|
3128
|
+
message: `<Button ${name}> must be a finite number.`
|
|
3129
|
+
});
|
|
3130
|
+
};
|
|
3131
|
+
const buttonNumber = (found, name, value, fallback) => {
|
|
3132
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
3133
|
+
invalidButtonNumber(found, name);
|
|
3134
|
+
return fallback;
|
|
3135
|
+
};
|
|
3136
|
+
const optionalButtonNumber = (found, name, value) => {
|
|
3137
|
+
if (value === void 0) return void 0;
|
|
3138
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
3139
|
+
invalidButtonNumber(found, name);
|
|
3140
|
+
};
|
|
3141
|
+
const optionalPositiveButtonNumber = (found, name, value) => {
|
|
3142
|
+
if (value === void 0) return void 0;
|
|
3143
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
3144
|
+
found.push({
|
|
3145
|
+
code: "invalid-button-number",
|
|
3146
|
+
severity: "error",
|
|
3147
|
+
message: `<Button ${name}> must be a positive finite number.`
|
|
3148
|
+
});
|
|
3149
|
+
};
|
|
3150
|
+
/**
|
|
3151
|
+
* A link styled as a button, with a VML fallback so classic Outlook draws the shape. Its fill,
|
|
3152
|
+
* label color, font and corners default to the theme (`bg-primary`, `text-primary-foreground`,
|
|
3153
|
+
* `font-sans`, `rounded`), so a class on the button restyles it. Classic Outlook draws the VML
|
|
3154
|
+
* shape from the props alone: set `backgroundColor`, `color` and `borderRadius` when the button
|
|
3155
|
+
* does not use the theme's primary fill.
|
|
3156
|
+
*/
|
|
3157
|
+
const Button = ({ className, href, width, height, paddingX = 16, backgroundColor, color, borderRadius, borderColor, borderWidth = 0, fontSize = 16, fontFamily, align = "left", style, children }) => {
|
|
3158
|
+
const found = [];
|
|
3159
|
+
const safeWidth = optionalButtonNumber(found, "width", width);
|
|
3160
|
+
const safeHeight = optionalPositiveButtonNumber(found, "height", height);
|
|
3161
|
+
const safePaddingX = buttonNumber(found, "paddingX", paddingX, 16);
|
|
3162
|
+
const safeBorderRadius = optionalButtonNumber(found, "borderRadius", borderRadius);
|
|
3163
|
+
const safeBorderWidth = buttonNumber(found, "borderWidth", borderWidth, 0);
|
|
3164
|
+
const safeFontSize = buttonNumber(found, "fontSize", fontSize, 16);
|
|
3165
|
+
const label = typeof children === "string" ? children : void 0;
|
|
3166
|
+
const fallback = safeHeight !== void 0 && label !== void 0;
|
|
3167
|
+
if (!fallback) found.push({
|
|
3168
|
+
code: "button-outlook-fallback",
|
|
3169
|
+
severity: "warning",
|
|
3170
|
+
message: "<Button> draws its classic Outlook shape from VML, which needs a height and a plain string label. Without both, classic Windows Outlook shows an unstyled link.",
|
|
3171
|
+
clients: ["outlook-windows"]
|
|
3172
|
+
});
|
|
3173
|
+
const arcsize = safeHeight === void 0 ? 0 : Math.round((safeBorderRadius ?? 0) / safeHeight * 100);
|
|
3174
|
+
const fallbackWidth = safeWidth ?? Math.ceil((label?.length ?? 0) * safeFontSize * BOLD_GLYPH_ADVANCE) + 2 * safePaddingX;
|
|
3175
|
+
const fill = backgroundColor ?? "var(--color-primary)";
|
|
3176
|
+
const ink = color ?? "var(--color-primary-foreground)";
|
|
3177
|
+
const family = fontFamily ?? "var(--font-sans)";
|
|
3178
|
+
const button = presentation({
|
|
3179
|
+
width: "100%",
|
|
3180
|
+
style: { borderCollapse: "collapse" }
|
|
3181
|
+
}, jsx("td", {
|
|
3182
|
+
align,
|
|
3183
|
+
children: [fallback ? rawMarkup(`<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeAttribute(href)}" style="height:${safeHeight}px;v-text-anchor:middle;width:${fallbackWidth}px;" arcsize="${arcsize}%" ${borderColor === void 0 ? "stroke=\"false\"" : `strokecolor="${escapeAttribute(borderColor)}" strokeweight="${safeBorderWidth}px"`} fillcolor="${escapeAttribute(fill)}"><w:anchorlock/><center style="color:${escapeAttribute(ink)};font-family:${escapeAttribute(family)};font-size:${safeFontSize}px;font-weight:bold;">${escapeText(label)}</center></v:roundrect><![endif]-->`) : null, jsx("a", {
|
|
3184
|
+
className: classNames(PRIMITIVE_CLASSES.button, className),
|
|
3185
|
+
href,
|
|
3186
|
+
target: "_blank",
|
|
3187
|
+
rel: "noopener noreferrer",
|
|
3188
|
+
style: {
|
|
3189
|
+
...backgroundColor === void 0 ? {} : { backgroundColor },
|
|
3190
|
+
...color === void 0 ? {} : { color },
|
|
3191
|
+
...safeBorderRadius === void 0 ? {} : { borderRadius: `${safeBorderRadius}px` },
|
|
3192
|
+
display: "inline-block",
|
|
3193
|
+
...fontFamily === void 0 ? {} : { fontFamily },
|
|
3194
|
+
fontSize: `${safeFontSize}px`,
|
|
3195
|
+
fontWeight: "bold",
|
|
3196
|
+
lineHeight: safeHeight === void 0 ? "120%" : `${safeHeight - 2 * safeBorderWidth}px`,
|
|
3197
|
+
textAlign: "center",
|
|
3198
|
+
textDecoration: "none",
|
|
3199
|
+
...safeWidth === void 0 ? { padding: `0 ${safePaddingX}px` } : {
|
|
3200
|
+
width: `${safeWidth}px`,
|
|
3201
|
+
maxWidth: "100%"
|
|
3202
|
+
},
|
|
3203
|
+
...borderColor === void 0 ? {} : { border: `${safeBorderWidth}px solid ${borderColor}` },
|
|
3204
|
+
...fallback ? { msoHide: "all" } : {},
|
|
3205
|
+
...style
|
|
3206
|
+
},
|
|
3207
|
+
children
|
|
3208
|
+
})]
|
|
3209
|
+
}));
|
|
3210
|
+
return withEmailDiagnostics(button, found);
|
|
3211
|
+
};
|
|
3212
|
+
/** Vertical space that survives clients which collapse margins. */
|
|
3213
|
+
const Spacer = ({ height }) => presentation({ width: "100%" }, jsx("td", {
|
|
3214
|
+
height,
|
|
3215
|
+
style: {
|
|
3216
|
+
height: `${height}px`,
|
|
3217
|
+
lineHeight: `${height}px`,
|
|
3218
|
+
fontSize: "1px"
|
|
3219
|
+
},
|
|
3220
|
+
children: ""
|
|
3221
|
+
}));
|
|
3222
|
+
/**
|
|
3223
|
+
* A horizontal rule drawn as a cell border, which every target client renders. It defaults to a
|
|
3224
|
+
* 1px line in the theme's `--color-border`.
|
|
3225
|
+
*/
|
|
3226
|
+
const Divider = ({ className, color, thickness, style }) => presentation({ width: "100%" }, jsx("td", {
|
|
3227
|
+
className: classNames(PRIMITIVE_CLASSES.divider, className),
|
|
3228
|
+
style: {
|
|
3229
|
+
...color === void 0 && thickness === void 0 ? {} : { borderTop: `${thickness ?? 1}px solid ${color ?? "var(--color-border)"}` },
|
|
3230
|
+
fontSize: "1px",
|
|
3231
|
+
lineHeight: "1px",
|
|
3232
|
+
...style
|
|
3233
|
+
},
|
|
3234
|
+
children: ""
|
|
3235
|
+
}));
|
|
3236
|
+
/** An image with the width/height attributes Outlook sizes from before CSS applies. */
|
|
3237
|
+
const Image = ({ className, src, alt, width, height, align, style }) => jsx("img", {
|
|
3238
|
+
className,
|
|
3239
|
+
src,
|
|
3240
|
+
alt,
|
|
3241
|
+
border: 0,
|
|
3242
|
+
...width === void 0 ? {} : { width },
|
|
3243
|
+
...height === void 0 ? {} : { height },
|
|
3244
|
+
...align === void 0 ? {} : { align },
|
|
3245
|
+
style: {
|
|
3246
|
+
display: "block",
|
|
3247
|
+
outline: "none",
|
|
3248
|
+
border: "none",
|
|
3249
|
+
textDecoration: "none",
|
|
3250
|
+
maxWidth: "100%",
|
|
3251
|
+
...style
|
|
3252
|
+
}
|
|
3253
|
+
});
|
|
3254
|
+
/**
|
|
3255
|
+
* A text link that opens outside the client and cannot reach back at the opener. It defaults to
|
|
3256
|
+
* the theme's `--color-primary`, underlined.
|
|
3257
|
+
*/
|
|
3258
|
+
const Link = ({ className, href, target = "_blank", rel = "noopener noreferrer", style, children }) => jsx("a", {
|
|
3259
|
+
className: classNames(PRIMITIVE_CLASSES.link, className),
|
|
3260
|
+
href,
|
|
3261
|
+
target,
|
|
3262
|
+
rel,
|
|
3263
|
+
...style === void 0 ? {} : { style },
|
|
3264
|
+
children
|
|
3265
|
+
});
|
|
3266
|
+
//#endregion
|
|
3267
|
+
export { reservedBrandAttributes as $, renderEmail as A, brandComponents as B, emailSelectionAncestry as C, previewEmail as D, emailSelections as E, propFor as F, emailPresentationOverrides as G, withoutUnsubscribeRow as H, rawParts as I, EMAIL_GLOBAL_ATTRIBUTES as J, isValueNode as K, PRIMITIVE_CLASSES as L, lowerEmailNodes as M, Tokens as N, RenderError as O, emailNodeToIr as P, parseBrandSpecifier as Q, PRIMITIVE_CSS as R, emailDefinitionInstances as S, emailSelectionSiblings as T, COMPONENT_DEGRADATIONS as U, renderedFooterMarkers as V, componentDegradation as W, isUrlAttribute as X, emailElement as Y, brandSpecifierPattern as Z, kebab as _, Email as a, INSTANCE_PATH_ATTRIBUTE as b, PREHEADER_ATTRIBUTE as c, equalColumnWidth as d, samvaBrandPlugin as et, preheaderNode as f, escapeText as g, escapeAttribute as h, Divider as i, smsSegmentInfo as j, render as k, Section as l, declarationValue as m, Column as n, Image as o, checkAttribute as p, EMAIL_ELEMENTS as q, Columns as r, Link as s, Button as t, COMPILED_TEMPLATE_VERSION as tt, Spacer as u, serializeEmailHtml as v, emailSelectionIndex as w, emailAuthoringTarget as x, serializeStyle as y, BRAND_COMPONENT_CLASSES as z };
|