@samva/markup 0.9.0 → 0.10.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.
Files changed (88) hide show
  1. package/AGENTS.md +48 -40
  2. package/CHANGELOG.md +345 -0
  3. package/README.md +6 -6
  4. package/dist/{brand-B-0bN_Ir.d.ts → assets-BIsyMQ92.d.ts} +41 -11
  5. package/dist/{email/brand.js → brand-C0MKTwmX.js} +64 -4
  6. package/dist/{theme-css.d.ts → brand.d.ts} +12 -3
  7. package/dist/brand.js +4 -0
  8. package/dist/compiler.d.ts +231 -11
  9. package/dist/compiler.js +1312 -13
  10. package/dist/components-BZCqegS8.js +1864 -0
  11. package/dist/diagnostic-model-DJtqbETd.js +38 -0
  12. package/dist/diagnostic-model-DxthUZBT.d.ts +31 -0
  13. package/dist/{email/diagnostics.js → diagnostics-CnKFefKz.js} +2 -2
  14. package/dist/diagnostics.d.ts +406 -27
  15. package/dist/diagnostics.js +402 -34
  16. package/dist/edit.d.ts +234 -0
  17. package/dist/{channel-edit.js → edit.js} +341 -3
  18. package/dist/email/jsx-dev-runtime.d.ts +1 -1
  19. package/dist/email/jsx-dev-runtime.js +1 -2
  20. package/dist/email/jsx-runtime.d.ts +1 -1
  21. package/dist/email/jsx-runtime.js +1 -85
  22. package/dist/{email/components.d.ts → email.d.ts} +2 -2
  23. package/dist/email.js +2 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/{cascade-t6kxlgEI.js → ir-CLhlklKV.js} +8 -4
  27. package/dist/jsx-runtime-CBlexoeR.js +98 -0
  28. package/dist/{elements-cqldpaJt.d.ts → jsx-runtime-ylIOnaSf.d.ts} +74 -74
  29. package/dist/{profile-BH-OtdDA.js → profile-pWGtWZXF.js} +153 -20
  30. package/dist/render-BPonDpCl.d.ts +539 -0
  31. package/dist/{email/render.js → render-BQmfh94k.js} +102 -8
  32. package/dist/render.d.ts +35 -0
  33. package/dist/render.js +49 -0
  34. package/dist/sms.d.ts +1 -1
  35. package/dist/sms.js +1 -1
  36. package/dist/{template-_QE_rQND.d.ts → template-DMPwxMSq.d.ts} +2 -2
  37. package/dist/{template.js → template-zLUjcwfF.js} +2 -2
  38. package/dist/{theme-css-hFqcPSar.js → theme-css-JtRCm1fb.js} +2 -2
  39. package/dist/whatsapp.d.ts +1 -1
  40. package/dist/whatsapp.js +1 -1
  41. package/generated/contract.json +16 -10
  42. package/generated/reference.md +17 -10
  43. package/package.json +36 -119
  44. package/samva-brand.d.ts +4 -4
  45. package/skills/sml/SKILL.md +2 -2
  46. package/dist/assets-C5duwTya.d.ts +0 -40
  47. package/dist/channel-edit.d.ts +0 -121
  48. package/dist/channel-specs-B6M_HlaV.js +0 -29
  49. package/dist/compatibility-DbISoFIq.js +0 -589
  50. package/dist/css-BVQKICKC.d.ts +0 -109
  51. package/dist/diagnostic-codes.d.ts +0 -403
  52. package/dist/diagnostic-codes.js +0 -399
  53. package/dist/email/assets.d.ts +0 -2
  54. package/dist/email/assets.js +0 -63
  55. package/dist/email/brand-footer.d.ts +0 -34
  56. package/dist/email/brand-footer.js +0 -63
  57. package/dist/email/brand.d.ts +0 -2
  58. package/dist/email/components.js +0 -275
  59. package/dist/email/diagnostics.d.ts +0 -2
  60. package/dist/email/elements.d.ts +0 -2
  61. package/dist/email/elements.js +0 -299
  62. package/dist/email/html.d.ts +0 -2
  63. package/dist/email/html.js +0 -321
  64. package/dist/email/render.d.ts +0 -40
  65. package/dist/email/text.d.ts +0 -7
  66. package/dist/email/text.js +0 -100
  67. package/dist/html-BJepOwZ4.d.ts +0 -56
  68. package/dist/ir-aaqNE987.d.ts +0 -252
  69. package/dist/ir.d.ts +0 -2
  70. package/dist/ir.js +0 -7
  71. package/dist/preview-Ekdg1dXG.d.ts +0 -100
  72. package/dist/preview.d.ts +0 -2
  73. package/dist/preview.js +0 -160
  74. package/dist/render-ir.d.ts +0 -19
  75. package/dist/render-ir.js +0 -826
  76. package/dist/sms-segments.d.ts +0 -13
  77. package/dist/sms-segments.js +0 -46
  78. package/dist/source-edit.d.ts +0 -116
  79. package/dist/source-edit.js +0 -343
  80. package/dist/source-locations.d.ts +0 -2
  81. package/dist/source-locations.js +0 -16
  82. package/dist/static.d.ts +0 -57
  83. package/dist/static.js +0 -69
  84. package/dist/tailwind-CMYpZf1v.js +0 -614
  85. package/dist/template.d.ts +0 -2
  86. package/dist/theme-css.js +0 -2
  87. package/dist/theme-imports-lvBw3AD1.js +0 -109
  88. package/dist/transform-Ct2OMkFN.d.ts +0 -153
@@ -0,0 +1,1864 @@
1
+ import { n as isObject } from "./guards-eV1qLbf2.js";
2
+ import { i as UNSUBSCRIBE_URL_PLACEHOLDER$1, n as RESERVED_FOOTER_ATTRIBUTES, r as UNSUBSCRIBE_LINK, t as BRAND_FOOTER_MARKER } from "./unsubscribe-link-CoesnQDU.js";
3
+ import { callFormatter, createFmt } from "./fmt.js";
4
+ import { a as jsx, c as rawMarkup, l as reportEmailDiagnostic, n as Fragment, s as normalizeChildren, t as EmailNode } from "./jsx-runtime-CBlexoeR.js";
5
+ //#region src/email/elements.ts
6
+ /** Attributes every supported element accepts. `class`/`className`/`style` are handled separately. */
7
+ const EMAIL_GLOBAL_ATTRIBUTES = {
8
+ id: { type: "string" },
9
+ title: { type: "string" },
10
+ lang: { type: "string" },
11
+ dir: {
12
+ type: "string",
13
+ values: [
14
+ "ltr",
15
+ "rtl",
16
+ "auto"
17
+ ]
18
+ },
19
+ role: { type: "string" }
20
+ };
21
+ const alignment = {
22
+ type: "string",
23
+ values: [
24
+ "left",
25
+ "center",
26
+ "right"
27
+ ]
28
+ };
29
+ const verticalAlignment = {
30
+ type: "string",
31
+ values: [
32
+ "top",
33
+ "middle",
34
+ "bottom"
35
+ ]
36
+ };
37
+ const flowAttributes = { align: alignment };
38
+ const cellAttributes = {
39
+ align: alignment,
40
+ valign: verticalAlignment,
41
+ width: { type: "measure" },
42
+ height: { type: "measure" },
43
+ bgcolor: { type: "string" },
44
+ colspan: { type: "number" },
45
+ rowspan: { type: "number" }
46
+ };
47
+ const block = (attributes) => ({
48
+ content: "flow",
49
+ layout: "block",
50
+ attributes
51
+ });
52
+ const inline = () => ({
53
+ content: "flow",
54
+ layout: "inline",
55
+ attributes: {}
56
+ });
57
+ /** The complete supported vocabulary. Anything outside it is a compile error with a source location. */
58
+ const EMAIL_ELEMENTS = {
59
+ html: {
60
+ content: "flow",
61
+ layout: "document",
62
+ attributes: {
63
+ xmlns: { type: "string" },
64
+ "xmlns:o": { type: "string" },
65
+ "xmlns:v": { type: "string" },
66
+ "xmlns:w": { type: "string" }
67
+ },
68
+ childElements: ["head", "body"]
69
+ },
70
+ head: {
71
+ content: "flow",
72
+ layout: "metadata",
73
+ attributes: {},
74
+ childElements: [
75
+ "title",
76
+ "meta",
77
+ "style"
78
+ ]
79
+ },
80
+ title: {
81
+ content: "text",
82
+ layout: "metadata",
83
+ attributes: {}
84
+ },
85
+ style: {
86
+ content: "raw",
87
+ layout: "metadata",
88
+ attributes: { type: { type: "string" } }
89
+ },
90
+ meta: {
91
+ content: "void",
92
+ layout: "metadata",
93
+ attributes: {
94
+ name: { type: "string" },
95
+ content: { type: "string" },
96
+ charset: { type: "string" },
97
+ "http-equiv": { type: "string" }
98
+ }
99
+ },
100
+ body: {
101
+ content: "flow",
102
+ layout: "block",
103
+ attributes: { bgcolor: { type: "string" } }
104
+ },
105
+ div: block(flowAttributes),
106
+ section: block(flowAttributes),
107
+ article: block(flowAttributes),
108
+ header: block(flowAttributes),
109
+ footer: block(flowAttributes),
110
+ main: block(flowAttributes),
111
+ center: block({}),
112
+ p: block(flowAttributes),
113
+ blockquote: block(flowAttributes),
114
+ pre: {
115
+ content: "flow",
116
+ layout: "preformatted",
117
+ attributes: {}
118
+ },
119
+ h1: block(flowAttributes),
120
+ h2: block(flowAttributes),
121
+ h3: block(flowAttributes),
122
+ h4: block(flowAttributes),
123
+ h5: block(flowAttributes),
124
+ h6: block(flowAttributes),
125
+ span: inline(),
126
+ strong: inline(),
127
+ b: inline(),
128
+ em: inline(),
129
+ i: inline(),
130
+ u: inline(),
131
+ s: inline(),
132
+ small: inline(),
133
+ code: inline(),
134
+ a: {
135
+ content: "flow",
136
+ layout: "link",
137
+ attributes: {
138
+ href: {
139
+ type: "url",
140
+ required: true
141
+ },
142
+ target: { type: "string" },
143
+ rel: { type: "string" },
144
+ name: { type: "string" }
145
+ }
146
+ },
147
+ img: {
148
+ content: "void",
149
+ layout: "image",
150
+ attributes: {
151
+ src: {
152
+ type: "url",
153
+ required: true
154
+ },
155
+ alt: {
156
+ type: "string",
157
+ required: true
158
+ },
159
+ width: { type: "measure" },
160
+ height: { type: "measure" },
161
+ border: { type: "measure" },
162
+ align: alignment
163
+ }
164
+ },
165
+ table: {
166
+ content: "flow",
167
+ layout: "table",
168
+ attributes: {
169
+ width: { type: "measure" },
170
+ height: { type: "measure" },
171
+ border: { type: "measure" },
172
+ cellpadding: { type: "measure" },
173
+ cellspacing: { type: "measure" },
174
+ align: alignment,
175
+ bgcolor: { type: "string" }
176
+ },
177
+ childElements: [
178
+ "caption",
179
+ "colgroup",
180
+ "thead",
181
+ "tbody",
182
+ "tfoot",
183
+ "tr"
184
+ ]
185
+ },
186
+ caption: {
187
+ content: "flow",
188
+ layout: "block",
189
+ attributes: flowAttributes
190
+ },
191
+ colgroup: {
192
+ content: "flow",
193
+ layout: "table-section",
194
+ attributes: {
195
+ span: { type: "number" },
196
+ width: { type: "measure" }
197
+ },
198
+ childElements: ["col"]
199
+ },
200
+ col: {
201
+ content: "void",
202
+ layout: "table-section",
203
+ attributes: {
204
+ span: { type: "number" },
205
+ width: { type: "measure" }
206
+ }
207
+ },
208
+ thead: {
209
+ content: "flow",
210
+ layout: "table-section",
211
+ attributes: {
212
+ align: alignment,
213
+ valign: verticalAlignment,
214
+ bgcolor: { type: "string" }
215
+ },
216
+ childElements: ["tr"]
217
+ },
218
+ tbody: {
219
+ content: "flow",
220
+ layout: "table-section",
221
+ attributes: {
222
+ align: alignment,
223
+ valign: verticalAlignment,
224
+ bgcolor: { type: "string" }
225
+ },
226
+ childElements: ["tr"]
227
+ },
228
+ tfoot: {
229
+ content: "flow",
230
+ layout: "table-section",
231
+ attributes: {
232
+ align: alignment,
233
+ valign: verticalAlignment,
234
+ bgcolor: { type: "string" }
235
+ },
236
+ childElements: ["tr"]
237
+ },
238
+ tr: {
239
+ content: "flow",
240
+ layout: "row",
241
+ attributes: {
242
+ align: alignment,
243
+ valign: verticalAlignment,
244
+ bgcolor: { type: "string" },
245
+ height: { type: "measure" }
246
+ },
247
+ childElements: ["td", "th"]
248
+ },
249
+ td: {
250
+ content: "flow",
251
+ layout: "cell",
252
+ attributes: cellAttributes
253
+ },
254
+ th: {
255
+ content: "flow",
256
+ layout: "cell",
257
+ attributes: cellAttributes
258
+ },
259
+ ul: {
260
+ content: "flow",
261
+ layout: "list",
262
+ attributes: {},
263
+ childElements: ["li"]
264
+ },
265
+ ol: {
266
+ content: "flow",
267
+ layout: "list",
268
+ attributes: {
269
+ start: { type: "number" },
270
+ type: { type: "string" }
271
+ },
272
+ childElements: ["li"]
273
+ },
274
+ li: {
275
+ content: "flow",
276
+ layout: "list-item",
277
+ attributes: {}
278
+ },
279
+ br: {
280
+ content: "void",
281
+ layout: "break",
282
+ attributes: {}
283
+ },
284
+ hr: {
285
+ content: "void",
286
+ layout: "rule",
287
+ attributes: {
288
+ width: { type: "measure" },
289
+ size: { type: "number" },
290
+ align: alignment
291
+ }
292
+ }
293
+ };
294
+ /** Attributes that must carry a URL; these are checked against the safe-scheme rule. */
295
+ const isUrlAttribute = (tag, attribute) => {
296
+ return EMAIL_ELEMENTS[tag]?.attributes[attribute]?.type === "url";
297
+ };
298
+ /** Look up one supported element, or `undefined` when the tag is outside the vocabulary. */
299
+ const emailElement = (tag) => Object.hasOwn(EMAIL_ELEMENTS, tag) ? EMAIL_ELEMENTS[tag] : void 0;
300
+ //#endregion
301
+ //#region src/render-ir.ts
302
+ var IrRenderError = class extends Error {
303
+ diagnostics;
304
+ constructor(diagnostics) {
305
+ super(`Template render rejected: ${diagnostics.map((item) => `${item.code}: ${item.message}`).join("; ")}`);
306
+ this.name = "IrRenderError";
307
+ this.diagnostics = diagnostics;
308
+ }
309
+ };
310
+ const fail = (code, message) => {
311
+ throw new IrRenderError([{
312
+ code,
313
+ message
314
+ }]);
315
+ };
316
+ const MAX_ITERATIONS = 1e4;
317
+ const MAX_DEPTH = 64;
318
+ /** Classic Outlook lays a document out correctly only under a transitional doctype. */
319
+ const EMAIL_DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
320
+ /** The per-recipient unsubscribe URL the send path substitutes; SES must not rewrite it. */
321
+ const UNSUBSCRIBE_URL_PLACEHOLDER = "%samva:unsubscribe-url%";
322
+ const PREHEADER_TARGET_LENGTH$1 = 200;
323
+ const PREHEADER_PADDING$1 = "\xA0‌​‍‎‏";
324
+ const escapeText$1 = (value) => value.replace(/[&<>]/g, (character) => ({
325
+ "&": "&amp;",
326
+ "<": "&lt;",
327
+ ">": "&gt;"
328
+ })[character] ?? character);
329
+ const escapeAttribute$1 = (value) => value.replace(/[&<>"']/g, (character) => ({
330
+ "&": "&amp;",
331
+ "<": "&lt;",
332
+ ">": "&gt;",
333
+ "\"": "&quot;",
334
+ "'": "&#39;"
335
+ })[character] ?? character);
336
+ const CONTROL_CHARACTERS$1 = /[\u0000-\u001f\u007f]/;
337
+ const SAFE_URL_SCHEME$1 = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
338
+ /** A delivered message can resolve only an absolute URL, so relative references are refused. */
339
+ const isSafeUrl$1 = (value) => !/^[\u0000- ]/.test(value) && !CONTROL_CHARACTERS$1.test(value) && !value.includes("\\") && SAFE_URL_SCHEME$1.test(value);
340
+ const CSS_PROPERTY$1 = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
341
+ const CSS_UNSAFE$1 = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
342
+ const CSS_URL$1 = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
343
+ const kebab$1 = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
344
+ const UNITLESS$1 = /* @__PURE__ */ new Set([
345
+ "opacity",
346
+ "zIndex",
347
+ "fontWeight",
348
+ "lineHeight",
349
+ "order",
350
+ "flexGrow",
351
+ "flexShrink",
352
+ "columnCount",
353
+ "orphans",
354
+ "widows",
355
+ "zoom",
356
+ "fillOpacity",
357
+ "strokeOpacity"
358
+ ]);
359
+ const declarationValue$1 = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS$1.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
360
+ const report$1 = (ctx, code, message) => {
361
+ ctx.diagnostics.push({
362
+ code,
363
+ message
364
+ });
365
+ };
366
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
367
+ const has = (value, key) => Object.hasOwn(value, key);
368
+ const FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set([
369
+ "__proto__",
370
+ "constructor",
371
+ "prototype"
372
+ ]);
373
+ const ARRAY_INDEX = /^(0|[1-9]\d*)$/;
374
+ const step = (value, segment) => {
375
+ if (FORBIDDEN_SEGMENTS.has(segment)) return void 0;
376
+ if (Array.isArray(value)) return ARRAY_INDEX.test(segment) && Object.hasOwn(value, segment) ? value[Number(segment)] : void 0;
377
+ if (isRecord(value) && Object.hasOwn(value, segment)) return value[segment];
378
+ };
379
+ const resolvePath = (ctx, scope, path) => {
380
+ if (typeof path !== "string" || path === "") return void 0;
381
+ const [first = "", ...rest] = path.split(".");
382
+ if (FORBIDDEN_SEGMENTS.has(first)) return void 0;
383
+ let current = first.startsWith("$") ? scope.get(first) : step(ctx.input, first);
384
+ for (const segment of rest) {
385
+ if (current === void 0 || current === null) return void 0;
386
+ current = step(current, segment);
387
+ }
388
+ return current;
389
+ };
390
+ const invalid = (message) => fail("invalid-value", message);
391
+ /** The text a value contributes: strings as is, finite numbers, nothing for null and booleans. */
392
+ const textOf = (value) => {
393
+ if (typeof value === "string") return value;
394
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : invalid("A non-finite number cannot be text.");
395
+ if (value === null || value === void 0 || typeof value === "boolean") return "";
396
+ return invalid("An object or array cannot be rendered as text.");
397
+ };
398
+ const finiteNumber = (value, what) => typeof value === "number" && Number.isFinite(value) ? value : invalid(`${what} must be a finite number.`);
399
+ const arithmetic = (op, left, right) => {
400
+ if ((op === "/" || op === "%") && right === 0) return invalid("Division by zero.");
401
+ let result;
402
+ switch (op) {
403
+ case "+":
404
+ result = left + right;
405
+ break;
406
+ case "-":
407
+ result = left - right;
408
+ break;
409
+ case "*":
410
+ result = left * right;
411
+ break;
412
+ case "/":
413
+ result = left / right;
414
+ break;
415
+ case "%":
416
+ result = left % right;
417
+ break;
418
+ default: return invalid(`Unknown arithmetic operator ${JSON.stringify(op)}.`);
419
+ }
420
+ return Number.isFinite(result) ? result : invalid("Arithmetic produced a non-finite number.");
421
+ };
422
+ const formatterContext = (ctx) => {
423
+ ctx.fmt ??= createFmt({
424
+ locale: ctx.locale,
425
+ timeZone: ctx.timeZone
426
+ });
427
+ return ctx.fmt;
428
+ };
429
+ const applyFormat = (ctx, name, args) => {
430
+ if (typeof name !== "string") return invalid("A format node needs a formatter name.");
431
+ try {
432
+ return callFormatter(formatterContext(ctx), name, args);
433
+ } catch (error) {
434
+ return fail("format-error", error instanceof Error ? error.message : String(error));
435
+ }
436
+ };
437
+ const nullish = (value) => value === void 0 ? null : value;
438
+ const compare = (op, left, right) => {
439
+ switch (op) {
440
+ case "eq": return nullish(left) === nullish(right);
441
+ case "ne": return nullish(left) !== nullish(right);
442
+ case "lt":
443
+ case "gt":
444
+ case "le":
445
+ case "ge": {
446
+ if (!(typeof left === "number" && typeof right === "number" || typeof left === "string" && typeof right === "string")) return false;
447
+ const a = left;
448
+ const b = right;
449
+ if (op === "lt") return a < b;
450
+ if (op === "gt") return a > b;
451
+ return op === "le" ? a <= b : a >= b;
452
+ }
453
+ default: return invalid(`Unknown comparison ${JSON.stringify(op)}.`);
454
+ }
455
+ };
456
+ const checkDepth = (depth) => {
457
+ if (depth > MAX_DEPTH) fail("render-limit", `Templates nest at most ${MAX_DEPTH} levels deep.`);
458
+ };
459
+ const evalArgs = (ctx, scope, args, depth) => Array.isArray(args) ? args.map((arg) => evalValue(ctx, scope, arg, depth + 1)) : invalid("Arguments must be an array.");
460
+ const evalValue = (ctx, scope, value, depth) => {
461
+ checkDepth(depth);
462
+ if (value === null || typeof value !== "object") return value;
463
+ if (Array.isArray(value)) return invalid("Unknown value node.");
464
+ const node = value;
465
+ if (has(node, "bind")) return resolvePath(ctx, scope, node.bind);
466
+ if (has(node, "lit")) return node.lit;
467
+ if (has(node, "concat")) return evalArgs(ctx, scope, node.concat, depth).map(textOf).join("");
468
+ if (has(node, "format")) return applyFormat(ctx, node.format, evalArgs(ctx, scope, node.args, depth));
469
+ 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);
470
+ if (has(node, "op")) {
471
+ const [left, right] = evalArgs(ctx, scope, node.args, depth);
472
+ return arithmetic(node.op, finiteNumber(left, "An arithmetic operand"), finiteNumber(right, "An arithmetic operand"));
473
+ }
474
+ if (has(node, "count")) {
475
+ const items = evalValue(ctx, scope, node.count, depth + 1);
476
+ if (!Array.isArray(items)) return invalid("count applies to an array.");
477
+ return selectItems(ctx, scope, items, String(node.as), node.where, depth).length;
478
+ }
479
+ if (has(node, "length")) {
480
+ const measured = evalValue(ctx, scope, node.length, depth + 1);
481
+ return typeof measured === "string" || Array.isArray(measured) ? measured.length : invalid("length applies to a string or an array.");
482
+ }
483
+ return invalid("Unknown value node.");
484
+ };
485
+ const evalPredicate = (ctx, scope, predicate, depth) => {
486
+ checkDepth(depth);
487
+ if (typeof predicate !== "object" || predicate === null || Array.isArray(predicate)) return invalid("Unknown predicate.");
488
+ const node = predicate;
489
+ if (has(node, "present")) return Boolean(resolvePath(ctx, scope, node.present));
490
+ if (has(node, "truthy")) return Boolean(evalValue(ctx, scope, node.truthy, depth + 1));
491
+ if (has(node, "not")) return !evalPredicate(ctx, scope, node.not, depth + 1);
492
+ if (has(node, "and")) return asArray(node.and).every((item) => evalPredicate(ctx, scope, item, depth + 1));
493
+ if (has(node, "or")) return asArray(node.or).some((item) => evalPredicate(ctx, scope, item, depth + 1));
494
+ if (has(node, "cmp")) {
495
+ const [left, right] = evalArgs(ctx, scope, node.args, depth);
496
+ return compare(node.cmp, left, right);
497
+ }
498
+ return invalid("Unknown predicate.");
499
+ };
500
+ const asArray = (value) => Array.isArray(value) ? value : invalid("Expected an array.");
501
+ const declarations$1 = (ctx, scope, entries, depth) => {
502
+ const parts = [];
503
+ for (const entry of entries) {
504
+ if (!Array.isArray(entry) || entry.length !== 2) {
505
+ report$1(ctx, "invalid-style", "A style declaration is a [property, value] pair.");
506
+ continue;
507
+ }
508
+ const [property, raw] = entry;
509
+ const value = evalValue(ctx, scope, raw, depth + 1);
510
+ if (value === void 0 || value === null) continue;
511
+ if (typeof property !== "string" || !CSS_PROPERTY$1.test(property)) {
512
+ report$1(ctx, "unsupported-style-property", `Style property ${JSON.stringify(property)} is not a CSS property name.`);
513
+ continue;
514
+ }
515
+ if (typeof value !== "string" && !(typeof value === "number" && Number.isFinite(value))) {
516
+ report$1(ctx, "invalid-style-value", `Style ${property} must be a string or finite number.`);
517
+ continue;
518
+ }
519
+ const text = declarationValue$1(property, value);
520
+ if (CSS_UNSAFE$1.test(text) || CONTROL_CHARACTERS$1.test(text)) {
521
+ report$1(ctx, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration.`);
522
+ continue;
523
+ }
524
+ let unsafeUrl;
525
+ for (const match of text.matchAll(CSS_URL$1)) if (!isSafeUrl$1(match[2] ?? "")) unsafeUrl = match[2] ?? "";
526
+ if (unsafeUrl !== void 0) {
527
+ report$1(ctx, "unsafe-url", `Style ${property} points at ${JSON.stringify(unsafeUrl)}; email URLs must use https:, mailto:, tel:, cid: or a fragment.`);
528
+ continue;
529
+ }
530
+ parts.push(`${kebab$1(property)}:${text}`);
531
+ }
532
+ return parts.join(";");
533
+ };
534
+ const styleText = (ctx, scope, style, depth) => {
535
+ checkDepth(depth);
536
+ if (typeof style === "string") {
537
+ if (CONTROL_CHARACTERS$1.test(style)) {
538
+ report$1(ctx, "unsafe-style-value", "A style contains control characters.");
539
+ return "";
540
+ }
541
+ return style;
542
+ }
543
+ if (Array.isArray(style)) return declarations$1(ctx, scope, style, depth);
544
+ 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);
545
+ report$1(ctx, "invalid-style", "An element style is a string, a declaration list or a conditional.");
546
+ return "";
547
+ };
548
+ const attributeText = (ctx, tag, name, spec, value) => {
549
+ if (value === void 0 || value === null || value === false) return void 0;
550
+ if (value === true) return "";
551
+ if (typeof value !== "string" && !(typeof value === "number" && Number.isFinite(value))) {
552
+ report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a string or finite number, not ${typeof value}.`);
553
+ return;
554
+ }
555
+ const text = String(value);
556
+ if (CONTROL_CHARACTERS$1.test(text)) {
557
+ report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`);
558
+ return;
559
+ }
560
+ if (spec.values !== void 0 && !spec.values.includes(text)) {
561
+ report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`);
562
+ return;
563
+ }
564
+ if (spec.type === "number" && !/^-?\d+$/.test(text)) {
565
+ report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`);
566
+ return;
567
+ }
568
+ if (spec.type === "measure" && !/^\d+(\.\d+)?%?$/.test(text)) {
569
+ report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`);
570
+ return;
571
+ }
572
+ if (spec.type === "url" && !isSafeUrl$1(text)) {
573
+ 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.`);
574
+ return;
575
+ }
576
+ return text;
577
+ };
578
+ const buildAttributes = (ctx, scope, tag, spec, node, props, depth) => {
579
+ const emitted = [];
580
+ const present = /* @__PURE__ */ new Set();
581
+ let classes;
582
+ const source = node.attrs ?? {};
583
+ if (!isRecord(source)) {
584
+ report$1(ctx, "invalid-attribute-value", `<${tag}> attrs must be an object.`);
585
+ return "";
586
+ }
587
+ for (const [key, raw] of Object.entries(source)) {
588
+ if (key === "key") continue;
589
+ const name = key === "className" ? "class" : key;
590
+ const value = evalValue(ctx, scope, raw, depth + 1);
591
+ props.set(name, value);
592
+ if (tag === "a" && name === "href" && raw === UNSUBSCRIBE_URL_PLACEHOLDER) {
593
+ present.add(name);
594
+ emitted.push(` href="${UNSUBSCRIBE_URL_PLACEHOLDER}" ses:no-track`);
595
+ continue;
596
+ }
597
+ if (name === "class") {
598
+ if (value === void 0 || value === null) continue;
599
+ if (typeof value !== "string") {
600
+ report$1(ctx, "invalid-attribute-value", `<${tag} class> must be a string.`);
601
+ continue;
602
+ }
603
+ if (CONTROL_CHARACTERS$1.test(value)) {
604
+ report$1(ctx, "invalid-attribute-value", `<${tag} class> contains control characters.`);
605
+ continue;
606
+ }
607
+ if (classes !== void 0) {
608
+ report$1(ctx, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`);
609
+ continue;
610
+ }
611
+ classes = value;
612
+ emitted.push(` class="${escapeAttribute$1(value)}"`);
613
+ continue;
614
+ }
615
+ if (name === "style") {
616
+ report$1(ctx, "invalid-style", `<${tag}> writes its style on the element, not as an attribute.`);
617
+ continue;
618
+ }
619
+ if (/^on/i.test(name)) {
620
+ report$1(ctx, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`);
621
+ continue;
622
+ }
623
+ if ((name === "data-samva-footer" || name === "data-samva-unsubscribe") && node.reserved !== true) {
624
+ report$1(ctx, "reserved-attribute", `<${tag} ${name}> is reserved for the brand footer.`);
625
+ continue;
626
+ }
627
+ if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
628
+ const text = attributeText(ctx, tag, name, { type: "string" }, value);
629
+ if (text !== void 0) emitted.push(` ${name}="${escapeAttribute$1(text)}"`);
630
+ continue;
631
+ }
632
+ const globals = EMAIL_GLOBAL_ATTRIBUTES;
633
+ const attribute = Object.hasOwn(globals, name) ? globals[name] : Object.hasOwn(spec.attributes, name) ? spec.attributes[name] : void 0;
634
+ if (attribute === void 0) {
635
+ report$1(ctx, "unsupported-attribute", `<${tag}> does not support the ${name} attribute.`);
636
+ continue;
637
+ }
638
+ present.add(name);
639
+ const text = attributeText(ctx, tag, name, attribute, value);
640
+ if (text !== void 0) emitted.push(` ${name}="${escapeAttribute$1(text)}"`);
641
+ }
642
+ 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.`);
643
+ if (node.style !== void 0) {
644
+ const css = styleText(ctx, scope, node.style, depth + 1);
645
+ if (css !== "") emitted.push(` style="${escapeAttribute$1(css)}"`);
646
+ }
647
+ return emitted.join("");
648
+ };
649
+ const checkContentModel$1 = (ctx, tag, spec, children) => {
650
+ if (spec.content === "void") {
651
+ if (children.length > 0) report$1(ctx, "void-element-children", `<${tag}> cannot have children.`);
652
+ return;
653
+ }
654
+ if (spec.content === "text" || spec.content === "raw") {
655
+ for (const child of children) if (child.t !== "text") report$1(ctx, "invalid-content", `<${tag}> accepts text only.`);
656
+ return;
657
+ }
658
+ if (spec.childElements === void 0) return;
659
+ for (const child of children) {
660
+ if (child.t === "raw") continue;
661
+ if (child.t === "text") {
662
+ if (child.v.trim() !== "") report$1(ctx, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`);
663
+ continue;
664
+ }
665
+ if (!spec.childElements.includes(child.tag)) report$1(ctx, "invalid-content", `<${tag}> accepts only ${spec.childElements.map((name) => `<${name}>`).join(", ")}; found <${child.tag}>.`);
666
+ }
667
+ };
668
+ const pushText = (out, value) => {
669
+ if (value === null || value === void 0 || typeof value === "boolean") return;
670
+ out.push({
671
+ t: "text",
672
+ v: textOf(value)
673
+ });
674
+ };
675
+ const evalElement = (ctx, scope, node, depth, out) => {
676
+ const tag = node.el;
677
+ if (ctx.plain) {
678
+ report$1(ctx, "unsupported-element", "A plain-text body cannot contain elements.");
679
+ return;
680
+ }
681
+ const spec = typeof tag === "string" ? emailElement(tag) : void 0;
682
+ if (spec === void 0) {
683
+ report$1(ctx, "unsupported-element", `<${String(tag)}> is not part of the supported email vocabulary.`);
684
+ return;
685
+ }
686
+ const children = [];
687
+ for (const child of node.children ?? []) evalNode(ctx, scope, child, depth + 1, children);
688
+ checkContentModel$1(ctx, tag, spec, children);
689
+ if (spec.content === "raw") {
690
+ const css = children.map((child) => child.t === "text" ? child.v : "").join("");
691
+ if (/<\/|<!--/i.test(css)) {
692
+ report$1(ctx, "unsafe-raw-text", `<${tag}> content cannot contain markup.`);
693
+ return;
694
+ }
695
+ }
696
+ const props = /* @__PURE__ */ new Map();
697
+ const attrs = buildAttributes(ctx, scope, tag, spec, node, props, depth);
698
+ out.push({
699
+ t: "el",
700
+ tag,
701
+ content: spec.content,
702
+ layout: spec.layout,
703
+ attrs,
704
+ props,
705
+ children,
706
+ src: node.src
707
+ });
708
+ };
709
+ const evalRaw = (ctx, scope, node, depth, out) => {
710
+ if (ctx.plain) {
711
+ out.push({
712
+ t: "text",
713
+ v: node.text === void 0 ? "" : textOf(evalValue(ctx, scope, node.text, depth + 1))
714
+ });
715
+ return;
716
+ }
717
+ const html = asArray(node.raw).map((part) => rawPart(ctx, scope, part, depth + 1)).join("");
718
+ const text = node.text === void 0 ? "" : textOf(evalValue(ctx, scope, node.text, depth + 1));
719
+ out.push({
720
+ t: "raw",
721
+ html,
722
+ text
723
+ });
724
+ };
725
+ const rawPart = (ctx, scope, part, depth) => {
726
+ if (typeof part === "string") return part;
727
+ const record = part;
728
+ if (isRecord(record) && has(record, "attr")) return escapeAttribute$1(textOf(evalValue(ctx, scope, record.attr, depth + 1)));
729
+ if (isRecord(record) && has(record, "text")) return escapeText$1(textOf(evalValue(ctx, scope, record.text, depth + 1)));
730
+ return invalid("A raw part is a string, {attr} or {text}.");
731
+ };
732
+ const loopKey = (name) => name.startsWith("$") ? name : `$${name}`;
733
+ /** The items that satisfy a `where` clause, each bound as the loop variable while it is tested. */
734
+ const selectItems = (ctx, scope, items, as, where, depth) => {
735
+ if (where === void 0) return items;
736
+ const key = loopKey(as);
737
+ return items.filter((item) => {
738
+ if (++ctx.iterations > MAX_ITERATIONS) fail("render-limit", `A render iterates at most ${MAX_ITERATIONS} times.`);
739
+ const inner = new Map(scope);
740
+ inner.set(key, item);
741
+ return evalPredicate(ctx, inner, where, depth + 1);
742
+ });
743
+ };
744
+ const evalEach = (ctx, scope, node, depth, out) => {
745
+ const source = evalValue(ctx, scope, node.each, depth + 1);
746
+ if (!Array.isArray(source)) invalid("each needs an array.");
747
+ const selected = selectItems(ctx, scope, source, String(node.as), node.where, depth);
748
+ let items = selected;
749
+ if (node.chunk !== void 0) {
750
+ if (!Number.isInteger(node.chunk) || node.chunk < 1) invalid("chunk is a positive integer.");
751
+ const slices = [];
752
+ for (let at = 0; at < selected.length; at += node.chunk) slices.push(selected.slice(at, at + node.chunk));
753
+ items = slices;
754
+ }
755
+ const itemKey = loopKey(String(node.as));
756
+ const indexKey = node.index === void 0 ? void 0 : loopKey(String(node.index));
757
+ for (const [index, item] of items.entries()) {
758
+ if (++ctx.iterations > MAX_ITERATIONS) fail("render-limit", `A render iterates at most ${MAX_ITERATIONS} times.`);
759
+ const inner = new Map(scope);
760
+ inner.set(itemKey, item);
761
+ if (indexKey !== void 0) inner.set(indexKey, index);
762
+ for (const child of asArray(node.children)) evalNode(ctx, inner, child, depth + 1, out);
763
+ }
764
+ };
765
+ const evalNode = (ctx, scope, node, depth, out) => {
766
+ checkDepth(depth);
767
+ if (node === null || typeof node !== "object") {
768
+ pushText(out, node);
769
+ return;
770
+ }
771
+ if (!isRecord(node)) {
772
+ invalid("An array cannot be rendered as a node.");
773
+ return;
774
+ }
775
+ if (has(node, "el")) return evalElement(ctx, scope, node, depth, out);
776
+ if (has(node, "each")) return evalEach(ctx, scope, node, depth, out);
777
+ if (has(node, "raw")) return evalRaw(ctx, scope, node, depth, out);
778
+ if (has(node, "preheader")) {
779
+ if (!ctx.markerUsed && ctx.preheader !== void 0 && !ctx.plain) {
780
+ ctx.markerUsed = true;
781
+ out.push(preheaderBlock(ctx, ctx.preheader));
782
+ }
783
+ return;
784
+ }
785
+ if (has(node, "if") && Array.isArray(node.then)) {
786
+ const branch = evalPredicate(ctx, scope, node.if, depth + 1) ? node.then : node.else ?? [];
787
+ for (const child of branch) evalNode(ctx, scope, child, depth + 1, out);
788
+ return;
789
+ }
790
+ pushText(out, evalValue(ctx, scope, node, depth + 1));
791
+ };
792
+ const PREHEADER_STYLE = [
793
+ ["display", "none"],
794
+ ["overflow", "hidden"],
795
+ ["lineHeight", "1px"],
796
+ ["opacity", 0],
797
+ ["maxHeight", 0],
798
+ ["maxWidth", 0]
799
+ ];
800
+ /** The hidden inbox-preview block: the text, then padding so the client cannot borrow body copy. */
801
+ const preheaderBlock = (ctx, text) => {
802
+ const children = [text];
803
+ if (text.length < PREHEADER_TARGET_LENGTH$1) children.push({
804
+ el: "div",
805
+ attrs: { "data-samva-preheader-padding": true },
806
+ children: [PREHEADER_PADDING$1.repeat(Math.ceil((PREHEADER_TARGET_LENGTH$1 - text.length) / 7))]
807
+ });
808
+ const out = [];
809
+ evalElement(ctx, /* @__PURE__ */ new Map(), {
810
+ el: "div",
811
+ attrs: { "data-samva-preheader": text },
812
+ style: PREHEADER_STYLE,
813
+ children
814
+ }, 0, out);
815
+ return out[0] ?? {
816
+ t: "text",
817
+ v: ""
818
+ };
819
+ };
820
+ /** Put the block first inside `<body>`, or return undefined when there is no body. */
821
+ const insertPreheader = (nodes, block) => {
822
+ for (const [index, node] of nodes.entries()) {
823
+ if (node.t !== "el") continue;
824
+ if (node.tag === "body") return nodes.with(index, {
825
+ ...node,
826
+ children: [block, ...node.children]
827
+ });
828
+ const replaced = insertPreheader(node.children, block);
829
+ if (replaced !== void 0) return nodes.with(index, {
830
+ ...node,
831
+ children: replaced
832
+ });
833
+ }
834
+ };
835
+ const serialize$1 = (node) => {
836
+ switch (node.t) {
837
+ case "text": return escapeText$1(node.v);
838
+ case "raw": return node.html;
839
+ case "el":
840
+ if (node.content === "raw") {
841
+ const css = node.children.map((child) => child.t === "text" ? child.v : "").join("");
842
+ return css === "" ? "" : `<${node.tag}${node.attrs}>${css}</${node.tag}>`;
843
+ }
844
+ if (node.content === "void") return `<${node.tag}${node.attrs} />`;
845
+ return `<${node.tag}${node.attrs}>${node.children.map(serialize$1).join("")}</${node.tag}>`;
846
+ }
847
+ };
848
+ /** Serialize while recording where each element lands, for the same document `serialize` writes. */
849
+ const serializeWithPositions = (nodes, offset) => {
850
+ const positions = [];
851
+ let written = offset;
852
+ const write = (node) => {
853
+ switch (node.t) {
854
+ case "text": {
855
+ const out = escapeText$1(node.v);
856
+ written += out.length;
857
+ return out;
858
+ }
859
+ case "raw":
860
+ written += node.html.length;
861
+ return node.html;
862
+ case "el": {
863
+ const start = written;
864
+ let out;
865
+ if (node.content === "raw") {
866
+ const css = node.children.map((child) => child.t === "text" ? child.v : "").join("");
867
+ out = css === "" ? "" : `<${node.tag}${node.attrs}>${css}</${node.tag}>`;
868
+ written += out.length;
869
+ } else if (node.content === "void") {
870
+ out = `<${node.tag}${node.attrs} />`;
871
+ written += out.length;
872
+ } else {
873
+ const open = `<${node.tag}${node.attrs}>`;
874
+ written += open.length;
875
+ const inner = node.children.map(write).join("");
876
+ const close = `</${node.tag}>`;
877
+ written += close.length;
878
+ out = `${open}${inner}${close}`;
879
+ }
880
+ positions.push({
881
+ start,
882
+ end: written,
883
+ tag: node.tag,
884
+ ...node.src === void 0 ? {} : { src: node.src }
885
+ });
886
+ return out;
887
+ }
888
+ }
889
+ };
890
+ return {
891
+ html: nodes.map(write).join(""),
892
+ positions
893
+ };
894
+ };
895
+ const PARAGRAPH = "\n\n";
896
+ const LINE = "\n";
897
+ const INVISIBLE = /​|‌|‍|‎|‏|⁠|/g;
898
+ const TABLE_SECTIONS = /* @__PURE__ */ new Set([
899
+ "thead",
900
+ "tbody",
901
+ "tfoot"
902
+ ]);
903
+ const isLayoutTable = (props) => props.get("role") === "presentation" || props.get("role") === "none";
904
+ const wrap = (inner, separator) => {
905
+ const trimmed = inner.replace(/^\s+/, "").replace(/\s+$/, "");
906
+ return trimmed === "" ? "" : `${separator}${trimmed}${separator}`;
907
+ };
908
+ const collect = (nodes, keep, descend) => nodes.flatMap((child) => {
909
+ if (child.t !== "el") return [];
910
+ if (keep(child.tag)) return [child];
911
+ return descend(child.tag) ? collect(child.children, keep, descend) : [];
912
+ });
913
+ const childText = (node, context) => node.children.map((child) => derive(child, context)).join("");
914
+ const isDecorativeCell = (cell, context) => {
915
+ const images = collect(cell.children, (tag) => tag === "img", () => true);
916
+ return images.length > 0 && images.every((image) => image.props.get("alt") === "") && childText(cell, context).trim() === "";
917
+ };
918
+ const deriveList = (node, context) => {
919
+ const indent = " ".repeat(context.listDepth);
920
+ const start = Number(node.props.get("start") ?? 1);
921
+ const first = Number.isFinite(start) ? start : 1;
922
+ const nested = {
923
+ ...context,
924
+ listDepth: context.listDepth + 1
925
+ };
926
+ const lines = collect(node.children, (tag) => tag === "li", () => false).map((item, index) => {
927
+ const marker = node.tag === "ol" ? `${first + index}.` : "-";
928
+ const continuation = `${indent}${" ".repeat(marker.length + 1)}`;
929
+ const [head = "", ...rest] = derive(item, nested).replace(/^\s+/, "").replace(/\s+$/, "").replace(/\n{2,}/g, LINE).split("\n");
930
+ return [`${indent}${marker} ${head}`, ...rest.map((line) => line.trim() === "" ? "" : /^\s/.test(line) ? line : `${continuation}${line}`)].join(LINE);
931
+ });
932
+ return wrap(lines.join(LINE), PARAGRAPH);
933
+ };
934
+ const deriveTable = (node, context) => {
935
+ if (isLayoutTable(node.props)) return childText(node, context);
936
+ 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(" | "));
937
+ return wrap(rows.join(LINE), PARAGRAPH);
938
+ };
939
+ const deriveElement = (node, context) => {
940
+ switch (node.layout) {
941
+ case "metadata": return "";
942
+ case "document":
943
+ case "inline":
944
+ case "list-item": return childText(node, context);
945
+ case "break": return LINE;
946
+ case "rule": return `${PARAGRAPH}---${PARAGRAPH}`;
947
+ case "image": {
948
+ const alt = node.props.get("alt");
949
+ const trimmed = typeof alt === "string" ? alt.trim() : "";
950
+ return trimmed === "" ? "" : `[${trimmed}]`;
951
+ }
952
+ case "link": {
953
+ const label = childText(node, context).replace(/\s+/g, " ").trim();
954
+ const target = node.props.get("href");
955
+ const href = typeof target === "string" ? target : "";
956
+ if (href === "" || href === label) return label;
957
+ return label === "" ? href : `${label} (${href})`;
958
+ }
959
+ case "list": return deriveList(node, context);
960
+ case "table": return deriveTable(node, context);
961
+ 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);
962
+ case "table-section":
963
+ case "cell": return wrap(childText(node, context), LINE);
964
+ case "preformatted": return wrap(childText(node, {
965
+ ...context,
966
+ preformatted: true
967
+ }), PARAGRAPH);
968
+ case "block": return wrap(childText(node, context), PARAGRAPH);
969
+ }
970
+ };
971
+ const derive = (node, context) => {
972
+ switch (node.t) {
973
+ case "text": return context.preformatted ? node.v : node.v.replace(INVISIBLE, "").replace(/[^\S\n]+/g, " ");
974
+ case "raw": return node.text;
975
+ case "el": return deriveElement(node, context);
976
+ }
977
+ };
978
+ 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();
979
+ const createContext = (ir, input, options, plain) => ({
980
+ input,
981
+ locale: options.locale ?? ir.locale,
982
+ timeZone: options.timeZone,
983
+ plain,
984
+ diagnostics: [],
985
+ iterations: 0,
986
+ fmt: void 0,
987
+ preheader: void 0,
988
+ markerUsed: false
989
+ });
990
+ const settle = (ctx) => {
991
+ if (ctx.diagnostics.length > 0) throw new IrRenderError(ctx.diagnostics);
992
+ };
993
+ /** Render the email channel of a compiled template against one validated input. */
994
+ const renderIr = (ir, input, options = {}) => {
995
+ const email = ir.email;
996
+ if (email === void 0) return fail("missing-channel", "The template has no email channel.");
997
+ const ctx = createContext(ir, input, options, false);
998
+ const empty = /* @__PURE__ */ new Map();
999
+ const subject = textOf(evalValue(ctx, empty, email.subject, 0));
1000
+ if (/[\r\n\u0000]/.test(subject)) fail("invalid-subject", "Invalid email subject: it must be one line of text.");
1001
+ if (email.preheader !== void 0) {
1002
+ const value = evalValue(ctx, empty, email.preheader, 0);
1003
+ if (value !== void 0 && value !== null) ctx.preheader = textOf(value);
1004
+ }
1005
+ const preheader = ctx.preheader;
1006
+ let tree = [];
1007
+ const nodes = [];
1008
+ evalNode(ctx, empty, email.body, 0, nodes);
1009
+ tree = nodes;
1010
+ if (preheader !== void 0 && !ctx.markerUsed) {
1011
+ const block = preheaderBlock(ctx, preheader);
1012
+ tree = insertPreheader(tree, block) ?? [block, ...tree];
1013
+ }
1014
+ settle(ctx);
1015
+ const text = normalize(tree.map((node) => derive(node, {
1016
+ preformatted: false,
1017
+ listDepth: 0
1018
+ })).join(""));
1019
+ if (options.positions === true) {
1020
+ const prefix = serializeWithPositions(tree, 0).html.startsWith("<html") ? EMAIL_DOCTYPE : "";
1021
+ const shifted = serializeWithPositions(tree, prefix.length);
1022
+ return {
1023
+ subject,
1024
+ preheader,
1025
+ html: `${prefix}${shifted.html}`,
1026
+ text,
1027
+ positions: shifted.positions
1028
+ };
1029
+ }
1030
+ const serialized = tree.map(serialize$1).join("");
1031
+ return {
1032
+ subject,
1033
+ preheader,
1034
+ html: serialized.startsWith("<html") ? `${EMAIL_DOCTYPE}${serialized}` : serialized,
1035
+ text
1036
+ };
1037
+ };
1038
+ const plainText = (ctx, nodes) => {
1039
+ const out = [];
1040
+ for (const node of asArray(nodes)) evalNode(ctx, /* @__PURE__ */ new Map(), node, 0, out);
1041
+ return out.map((node) => node.t === "text" ? node.v : node.t === "raw" ? node.text : "").join("");
1042
+ };
1043
+ /** Render the SMS channel of a compiled template to plain text. */
1044
+ const renderIrSms = (ir, input, options = {}) => {
1045
+ const sms = ir.sms;
1046
+ if (sms === void 0) return fail("missing-channel", "The template has no SMS channel.");
1047
+ const ctx = createContext(ir, input, options, true);
1048
+ const text = plainText(ctx, sms.body);
1049
+ settle(ctx);
1050
+ return text;
1051
+ };
1052
+ const resolveButtons = (ctx, nodes, out, depth) => {
1053
+ checkDepth(depth);
1054
+ const scope = /* @__PURE__ */ new Map();
1055
+ const value = (part) => textOf(evalValue(ctx, scope, part, 0));
1056
+ for (const node of asArray(nodes)) {
1057
+ if ("if" in node) {
1058
+ const branch = evalPredicate(ctx, scope, node.if, depth + 1) ? node.then : node.else ?? [];
1059
+ resolveButtons(ctx, branch, out, depth + 1);
1060
+ continue;
1061
+ }
1062
+ switch (node.type) {
1063
+ case "quick-reply":
1064
+ out.push({
1065
+ type: "quick-reply",
1066
+ text: value(node.text)
1067
+ });
1068
+ break;
1069
+ case "url": {
1070
+ const url = value(node.url);
1071
+ if (!isSafeUrl$1(url)) invalid("A button URL must be an absolute https URL.");
1072
+ out.push({
1073
+ type: "url",
1074
+ text: value(node.text),
1075
+ url
1076
+ });
1077
+ break;
1078
+ }
1079
+ case "phone":
1080
+ out.push({
1081
+ type: "phone",
1082
+ text: value(node.text),
1083
+ phone: value(node.phone)
1084
+ });
1085
+ break;
1086
+ case "copy-code":
1087
+ out.push({
1088
+ type: "copy-code",
1089
+ code: value(node.code)
1090
+ });
1091
+ break;
1092
+ default: invalid("Unknown WhatsApp button.");
1093
+ }
1094
+ }
1095
+ };
1096
+ /** Render the WhatsApp channel of a compiled template: every text rendered, conditional buttons resolved. */
1097
+ const renderIrWhatsApp = (ir, input, options = {}) => {
1098
+ const whatsapp = ir.whatsapp;
1099
+ if (whatsapp === void 0) return fail("missing-channel", "The template has no WhatsApp channel.");
1100
+ const ctx = createContext(ir, input, options, true);
1101
+ const header = whatsapp.header === void 0 ? void 0 : {
1102
+ type: whatsapp.header.type,
1103
+ text: plainText(ctx, whatsapp.header.content)
1104
+ };
1105
+ if (header !== void 0 && header.type !== "text" && !isSafeUrl$1(header.text)) invalid("A media header URL must be an absolute https URL.");
1106
+ const body = plainText(ctx, whatsapp.body);
1107
+ const footer = whatsapp.footer === void 0 ? void 0 : plainText(ctx, whatsapp.footer);
1108
+ const buttons = [];
1109
+ resolveButtons(ctx, whatsapp.buttons ?? [], buttons, 0);
1110
+ settle(ctx);
1111
+ return {
1112
+ ...whatsapp.name === void 0 ? {} : { name: whatsapp.name },
1113
+ language: whatsapp.language,
1114
+ category: whatsapp.category,
1115
+ ...header === void 0 ? {} : { header },
1116
+ body,
1117
+ ...footer === void 0 ? {} : { footer },
1118
+ buttons
1119
+ };
1120
+ };
1121
+ //#endregion
1122
+ //#region src/preview.ts
1123
+ /**
1124
+ * The attribute a preview stamps so a click in a rendered document resolves to exactly one
1125
+ * rendered element. A delivered message never carries it.
1126
+ */
1127
+ const INSTANCE_PATH_ATTRIBUTE = "data-samva-instance";
1128
+ const locationKey = (origin) => `${origin.fileName}:${origin.lineNumber}:${origin.columnNumber}`;
1129
+ const originKey = (selection) => `${selection.tag}\u0000${selection.origins.map(locationKey).join("\0")}`;
1130
+ const parentOf = (instancePath) => {
1131
+ const cut = instancePath.lastIndexOf(".");
1132
+ return cut === -1 ? void 0 : instancePath.slice(0, cut);
1133
+ };
1134
+ /**
1135
+ * Turn one render's positions into selections, in document order. `occurrence` and `occurrences`
1136
+ * count the rendered elements that share an authored origin and tag.
1137
+ */
1138
+ const emailSelections = (positions) => {
1139
+ const counts = /* @__PURE__ */ new Map();
1140
+ for (const position of positions) {
1141
+ const key = originKey(position);
1142
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1143
+ }
1144
+ const seen = /* @__PURE__ */ new Map();
1145
+ return [...positions].toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start).map((position) => {
1146
+ const key = originKey(position);
1147
+ const occurrence = (seen.get(key) ?? 0) + 1;
1148
+ seen.set(key, occurrence);
1149
+ const parentPath = parentOf(position.instancePath);
1150
+ return {
1151
+ instancePath: position.instancePath,
1152
+ tag: position.tag,
1153
+ origins: position.origins,
1154
+ authored: position.authored,
1155
+ occurrence,
1156
+ occurrences: counts.get(key) ?? 1,
1157
+ ...parentPath === void 0 ? {} : { parentPath },
1158
+ start: position.start,
1159
+ end: position.end
1160
+ };
1161
+ });
1162
+ };
1163
+ /**
1164
+ * Render one fixture with an instance path stamped on every element, and the selection each path
1165
+ * resolves to. The selections come from the spans the compiler recorded, so a click on the canvas
1166
+ * lands on the exact node of the source that produced it.
1167
+ */
1168
+ const renderIrPreview = (ir, input, options = {}) => {
1169
+ const rendered = renderIr(ir, input, {
1170
+ ...options,
1171
+ positions: true
1172
+ });
1173
+ const sources = ir.sources ?? [];
1174
+ const ordered = (rendered.positions ?? []).map((position) => {
1175
+ const src = position.src;
1176
+ const origins = src === void 0 ? [] : [{
1177
+ fileName: sources[src[2] ?? 0] ?? "",
1178
+ lineNumber: src[0],
1179
+ columnNumber: src[1]
1180
+ }];
1181
+ return {
1182
+ start: position.start,
1183
+ end: position.end,
1184
+ tag: position.tag,
1185
+ src: src === void 0 ? void 0 : src.join(":"),
1186
+ origins,
1187
+ path: "",
1188
+ parent: void 0
1189
+ };
1190
+ }).toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start);
1191
+ const open = [];
1192
+ const childCount = /* @__PURE__ */ new Map();
1193
+ for (const element of ordered) {
1194
+ while (open.length > 0 && open[open.length - 1].end <= element.start) open.pop();
1195
+ const parent = open[open.length - 1];
1196
+ const index = childCount.get(parent) ?? 0;
1197
+ childCount.set(parent, index + 1);
1198
+ element.parent = parent;
1199
+ element.path = parent === void 0 ? String(index) : `${parent.path}.${index}`;
1200
+ open.push(element);
1201
+ }
1202
+ const inserts = ordered.map((element) => ({
1203
+ at: element.start + 1 + element.tag.length,
1204
+ text: ` ${INSTANCE_PATH_ATTRIBUTE}="${element.path}"`
1205
+ }));
1206
+ inserts.sort((left, right) => left.at - right.at);
1207
+ let html = "";
1208
+ let cursor = 0;
1209
+ for (const insert of inserts) {
1210
+ html += rendered.html.slice(cursor, insert.at) + insert.text;
1211
+ cursor = insert.at;
1212
+ }
1213
+ html += rendered.html.slice(cursor);
1214
+ const shift = (offset) => {
1215
+ let moved = 0;
1216
+ for (const insert of inserts) {
1217
+ if (insert.at > offset) break;
1218
+ moved += insert.text.length;
1219
+ }
1220
+ return offset + moved;
1221
+ };
1222
+ const positions = ordered.map((element) => ({
1223
+ start: shift(element.start),
1224
+ end: shift(element.end),
1225
+ origins: element.origins,
1226
+ instancePath: element.path,
1227
+ tag: element.tag,
1228
+ authored: element.src !== void 0 && (element.parent === void 0 || element.parent.src !== element.src)
1229
+ }));
1230
+ const selections = emailSelections(positions);
1231
+ return {
1232
+ subject: rendered.subject,
1233
+ preheader: rendered.preheader,
1234
+ html,
1235
+ text: rendered.text,
1236
+ selections
1237
+ };
1238
+ };
1239
+ /** Look selections up by the instance path a rendered document carries. */
1240
+ const emailSelectionIndex = (selections) => new Map(selections.map((selection) => [selection.instancePath, selection]));
1241
+ /**
1242
+ * The authoring element a selection edits. For an authored element that is the element itself; for
1243
+ * a generated wrapper it is the primitive that asked for it. Undefined only where the compiler
1244
+ * generated markup with no authoring behind it, which nothing can edit in place.
1245
+ */
1246
+ const emailAuthoringTarget = (selection) => selection.origins[0];
1247
+ /** Root-first ancestry of a selection, itself last. */
1248
+ const emailSelectionAncestry = (index, instancePath) => {
1249
+ const chain = [];
1250
+ let current = index.get(instancePath);
1251
+ while (current !== void 0) {
1252
+ chain.unshift(current);
1253
+ current = current.parentPath === void 0 ? void 0 : index.get(current.parentPath);
1254
+ }
1255
+ return chain;
1256
+ };
1257
+ /**
1258
+ * Every rendered instance produced by the same authored element, in document order. One entry
1259
+ * means it rendered once; several mean it repeated.
1260
+ */
1261
+ const emailSelectionSiblings = (selections, selection) => {
1262
+ const key = originKey(selection);
1263
+ return selections.filter((candidate) => originKey(candidate) === key);
1264
+ };
1265
+ /**
1266
+ * Every rendered instance of the same authored element. A partial edited at its definition changes
1267
+ * all of these, which is the scope a control has to name before it applies.
1268
+ */
1269
+ const emailDefinitionInstances = (selections, selection) => {
1270
+ const authoring = selection.origins[0];
1271
+ const key = `${selection.tag}\u0000${authoring === void 0 ? "" : locationKey(authoring)}`;
1272
+ return selections.filter((candidate) => {
1273
+ const origin = candidate.origins[0];
1274
+ return `${candidate.tag}\u0000${origin === void 0 ? "" : locationKey(origin)}` === key;
1275
+ });
1276
+ };
1277
+ //#endregion
1278
+ //#region src/email/html.ts
1279
+ /** Escape a value for element content. */
1280
+ const escapeText = (value) => value.replace(/[&<>]/g, (character) => ({
1281
+ "&": "&amp;",
1282
+ "<": "&lt;",
1283
+ ">": "&gt;"
1284
+ })[character] ?? character);
1285
+ /** Escape a value for a double-quoted attribute. */
1286
+ const escapeAttribute = (value) => value.replace(/[&<>"']/g, (character) => ({
1287
+ "&": "&amp;",
1288
+ "<": "&lt;",
1289
+ ">": "&gt;",
1290
+ "\"": "&quot;",
1291
+ "'": "&#39;"
1292
+ })[character] ?? character);
1293
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
1294
+ const SAFE_URL_SCHEME = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
1295
+ const RELATIVE_URL = /^(\.{1,2}\/|\/(?!\/))/;
1296
+ /**
1297
+ * Only references an email client will follow safely; no `javascript:`, no
1298
+ * `data:`. A relative reference is legal only for an export: a file on disk can
1299
+ * resolve one against its own directory, and a delivered message cannot resolve
1300
+ * anything but an absolute URL.
1301
+ */
1302
+ const isSafeUrl = (value, relative) => !/^[\u0000-\u0020]/.test(value) && !CONTROL_CHARACTERS.test(value) && !value.includes("\\") && (SAFE_URL_SCHEME.test(value) || relative && RELATIVE_URL.test(value));
1303
+ const CSS_PROPERTY = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
1304
+ const CSS_UNSAFE = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
1305
+ const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
1306
+ /** camelCase to kebab-case; already-kebab names pass through unchanged. */
1307
+ const kebab = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
1308
+ const UNITLESS = /* @__PURE__ */ new Set([
1309
+ "opacity",
1310
+ "zIndex",
1311
+ "fontWeight",
1312
+ "lineHeight",
1313
+ "order",
1314
+ "flexGrow",
1315
+ "flexShrink",
1316
+ "columnCount",
1317
+ "orphans",
1318
+ "widows",
1319
+ "zoom",
1320
+ "fillOpacity",
1321
+ "strokeOpacity"
1322
+ ]);
1323
+ /** A bare number is pixels for every property that takes a length. */
1324
+ const declarationValue = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
1325
+ const report = (sink, code, message, origins, severity = "error") => {
1326
+ sink.diagnostics.push({
1327
+ code,
1328
+ severity,
1329
+ message,
1330
+ origins
1331
+ });
1332
+ };
1333
+ const declarations = (sink, style, origins) => {
1334
+ const parts = [];
1335
+ for (const [property, value] of Object.entries(style)) {
1336
+ if (value === void 0 || value === null) continue;
1337
+ if (!CSS_PROPERTY.test(property)) {
1338
+ 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);
1339
+ continue;
1340
+ }
1341
+ if (typeof value !== "string" && typeof value !== "number") {
1342
+ report(sink, "invalid-style-value", `Style ${property} must be a string or number, not ${typeof value}.`, origins);
1343
+ continue;
1344
+ }
1345
+ const text = declarationValue(property, value);
1346
+ if (CSS_UNSAFE.test(text) || CONTROL_CHARACTERS.test(text)) {
1347
+ report(sink, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration; remove ${JSON.stringify(text)}.`, origins);
1348
+ continue;
1349
+ }
1350
+ let unsafeUrl;
1351
+ for (const match of text.matchAll(CSS_URL)) if (!isSafeUrl(match[2] ?? "", sink.relativeUrls)) unsafeUrl = match[2] ?? "";
1352
+ if (unsafeUrl !== void 0) {
1353
+ 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);
1354
+ continue;
1355
+ }
1356
+ parts.push(`${kebab(property)}:${text}`);
1357
+ }
1358
+ return parts.join(";");
1359
+ };
1360
+ const attributeValue = (sink, tag, name, spec, value, origins) => {
1361
+ if (value === void 0 || value === null || value === false) return void 0;
1362
+ if (value === true) return "";
1363
+ if (typeof value !== "string" && typeof value !== "number") {
1364
+ report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a string or number, not ${typeof value}.`, origins);
1365
+ return;
1366
+ }
1367
+ const text = String(value);
1368
+ if (CONTROL_CHARACTERS.test(text)) {
1369
+ report(sink, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`, origins);
1370
+ return;
1371
+ }
1372
+ if (spec.values !== void 0 && !spec.values.includes(text)) {
1373
+ report(sink, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`, origins);
1374
+ return;
1375
+ }
1376
+ if (spec.type === "number" && !/^-?\d+$/.test(text)) {
1377
+ report(sink, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`, origins);
1378
+ return;
1379
+ }
1380
+ if (spec.type === "measure" && !/^\d+(\.\d+)?%?$/.test(text)) {
1381
+ report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`, origins);
1382
+ return;
1383
+ }
1384
+ if (spec.type === "url" && !isSafeUrl(text, sink.relativeUrls)) {
1385
+ 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);
1386
+ return;
1387
+ }
1388
+ return text;
1389
+ };
1390
+ const attributes = (sink, tag, spec, props, origins) => {
1391
+ const emitted = [];
1392
+ const present = /* @__PURE__ */ new Set();
1393
+ let classes;
1394
+ const unsubscribeLink = tag === "a" && props[UNSUBSCRIBE_LINK] === true;
1395
+ for (const [key, value] of Object.entries(props)) {
1396
+ if (key === "key") continue;
1397
+ const name = key === "className" ? "class" : key;
1398
+ if (unsubscribeLink && name === "href" && value === "%samva:unsubscribe-url%") {
1399
+ present.add(name);
1400
+ emitted.push(` href="${UNSUBSCRIBE_URL_PLACEHOLDER$1}" ses:no-track`);
1401
+ continue;
1402
+ }
1403
+ if (name === "class") {
1404
+ if (value === void 0 || value === null) continue;
1405
+ if (typeof value !== "string") {
1406
+ report(sink, "invalid-attribute-value", `<${tag} class> must be a string.`, origins);
1407
+ continue;
1408
+ }
1409
+ if (classes !== void 0) {
1410
+ report(sink, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`, origins);
1411
+ continue;
1412
+ }
1413
+ classes = value;
1414
+ emitted.push(` class="${escapeAttribute(value)}"`);
1415
+ continue;
1416
+ }
1417
+ if (name === "style") {
1418
+ if (value === void 0 || value === null) continue;
1419
+ if (!isObject(value) || Array.isArray(value)) {
1420
+ report(sink, "invalid-style", `<${tag} style> must be a property object, for example style={{ backgroundColor: "#fff" }}.`, origins);
1421
+ continue;
1422
+ }
1423
+ const css = declarations(sink, value, origins);
1424
+ if (css !== "") emitted.push(` style="${escapeAttribute(css)}"`);
1425
+ continue;
1426
+ }
1427
+ if (/^on/i.test(name)) {
1428
+ report(sink, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`, origins);
1429
+ continue;
1430
+ }
1431
+ if (RESERVED_FOOTER_ATTRIBUTES.has(name) && props[BRAND_FOOTER_MARKER] !== true) {
1432
+ report(sink, "reserved-attribute", `<${tag} ${name}> is written only by BrandFooter from samva:brand; render BrandFooter instead of marking an element as the footer.`, origins);
1433
+ continue;
1434
+ }
1435
+ if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
1436
+ const text = attributeValue(sink, tag, name, { type: "string" }, value, origins);
1437
+ if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
1438
+ continue;
1439
+ }
1440
+ const globalAttributes = EMAIL_GLOBAL_ATTRIBUTES;
1441
+ const attribute = Object.hasOwn(globalAttributes, name) ? globalAttributes[name] : Object.hasOwn(spec.attributes, name) ? spec.attributes[name] : void 0;
1442
+ if (attribute === void 0) {
1443
+ const supported = [
1444
+ ...Object.keys(EMAIL_GLOBAL_ATTRIBUTES),
1445
+ ...Object.keys(spec.attributes),
1446
+ "class",
1447
+ "style"
1448
+ ].sort().join(", ");
1449
+ report(sink, "unsupported-attribute", `<${tag}> does not support the ${name} attribute. Supported: ${supported}, plus data-* and aria-*.`, origins);
1450
+ continue;
1451
+ }
1452
+ present.add(name);
1453
+ const text = attributeValue(sink, tag, name, attribute, value, origins);
1454
+ if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
1455
+ }
1456
+ 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);
1457
+ return emitted.join("");
1458
+ };
1459
+ /** Fragments are transparent to a content model; raw compatibility markup is not content. */
1460
+ const contentChildren = (children) => children.flatMap((child) => child.type === "Fragment" ? contentChildren(child.children) : [child]);
1461
+ const checkContentModel = (sink, tag, spec, children, origins) => {
1462
+ if (spec.content === "void") {
1463
+ if (contentChildren(children).length > 0) report(sink, "void-element-children", `<${tag}> cannot have children.`, origins);
1464
+ return;
1465
+ }
1466
+ if (spec.content === "text" || spec.content === "raw") {
1467
+ for (const child of contentChildren(children)) if (child.type !== "Text") report(sink, "invalid-content", `<${tag}> accepts text only.`, child.type === "Element" ? child.origins : origins);
1468
+ return;
1469
+ }
1470
+ if (spec.childElements === void 0) return;
1471
+ for (const child of contentChildren(children)) {
1472
+ if (child.type === "Raw") continue;
1473
+ if (child.type === "Text") {
1474
+ if (child.value.trim() !== "") report(sink, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`, origins);
1475
+ continue;
1476
+ }
1477
+ 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);
1478
+ }
1479
+ };
1480
+ /** Every write goes through here so `sink.written` stays the emitted offset. */
1481
+ const emit = (sink, text) => {
1482
+ sink.written += text.length;
1483
+ return text;
1484
+ };
1485
+ const serialize = (sink, node, parentPath, counter) => {
1486
+ switch (node.type) {
1487
+ case "Text": return emit(sink, escapeText(node.value));
1488
+ case "Raw": return emit(sink, node.html);
1489
+ case "Fragment": return node.children.map((child) => serialize(sink, child, parentPath, counter)).join("");
1490
+ case "Element": {
1491
+ const start = sink.written;
1492
+ const index = counter.next++;
1493
+ const path = parentPath === "" ? String(index) : `${parentPath}.${index}`;
1494
+ const own = { next: 0 };
1495
+ const stamp = sink.instancePaths ? ` ${INSTANCE_PATH_ATTRIBUTE}="${path}"` : "";
1496
+ const spec = emailElement(node.tag);
1497
+ if (spec === void 0) {
1498
+ report(sink, "unsupported-element", `<${node.tag}> is not part of the supported email vocabulary. Supported elements: ${Object.keys(EMAIL_ELEMENTS).join(", ")}.`, node.origins);
1499
+ return "";
1500
+ }
1501
+ checkContentModel(sink, node.tag, spec, node.children, node.origins);
1502
+ if (spec.content === "raw") {
1503
+ const css = node.children.map((child) => child.type === "Text" ? child.value : "").join("");
1504
+ if (/<\/|<!--/i.test(css)) {
1505
+ report(sink, "unsafe-raw-text", `<${node.tag}> content cannot contain markup.`, node.origins);
1506
+ return "";
1507
+ }
1508
+ const rawAttrs = attributes(sink, node.tag, spec, node.props, node.origins);
1509
+ const raw = css === "" ? "" : `<${node.tag}${rawAttrs}${stamp}>${css}</${node.tag}>`;
1510
+ return record(sink, start, node, path, emit(sink, raw));
1511
+ }
1512
+ const attrs = attributes(sink, node.tag, spec, node.props, node.origins);
1513
+ if (spec.content === "void") return record(sink, start, node, path, emit(sink, `<${node.tag}${attrs}${stamp} />`));
1514
+ const open = emit(sink, `<${node.tag}${attrs}${stamp}>`);
1515
+ const children = node.children.map((child) => serialize(sink, child, path, own)).join("");
1516
+ const close = emit(sink, `</${node.tag}>`);
1517
+ return record(sink, start, node, path, `${open}${children}${close}`);
1518
+ }
1519
+ }
1520
+ };
1521
+ /** Record an element's emitted range once its content is written. */
1522
+ const record = (sink, start, node, instancePath, text) => {
1523
+ sink.positions.push({
1524
+ start,
1525
+ end: sink.written,
1526
+ origins: node.origins,
1527
+ instancePath,
1528
+ tag: node.tag,
1529
+ authored: node.authored
1530
+ });
1531
+ return text;
1532
+ };
1533
+ /** Lower the derived tree to email HTML, reporting every unsupported construct with its origin. */
1534
+ const serializeEmailHtml = (node, options = {}) => {
1535
+ const sink = {
1536
+ diagnostics: [],
1537
+ positions: [],
1538
+ written: 0,
1539
+ relativeUrls: options.relativeUrls === true,
1540
+ instancePaths: options.instancePaths === true
1541
+ };
1542
+ return {
1543
+ html: serialize(sink, node, "", { next: 0 }),
1544
+ diagnostics: sink.diagnostics,
1545
+ positions: sink.positions
1546
+ };
1547
+ };
1548
+ /** An authored style object as a declaration list, checked by the same rules as a serialized tree. */
1549
+ const serializeStyle = (style, origins) => {
1550
+ const sink = {
1551
+ diagnostics: [],
1552
+ positions: [],
1553
+ written: 0,
1554
+ relativeUrls: false,
1555
+ instancePaths: false
1556
+ };
1557
+ return {
1558
+ css: declarations(sink, style, origins),
1559
+ diagnostics: sink.diagnostics
1560
+ };
1561
+ };
1562
+ /**
1563
+ * Check one static attribute value against its element's spec, as serialization does. Returns
1564
+ * the text that would be emitted, or `undefined` when the attribute is dropped or invalid.
1565
+ */
1566
+ const checkAttribute = (tag, name, value, origins) => {
1567
+ const sink = {
1568
+ diagnostics: [],
1569
+ positions: [],
1570
+ written: 0,
1571
+ relativeUrls: false,
1572
+ instancePaths: false
1573
+ };
1574
+ const element = emailElement(tag);
1575
+ const globals = EMAIL_GLOBAL_ATTRIBUTES;
1576
+ const attributeSpec = () => {
1577
+ if (/^(data|aria)-[a-z0-9-]+$/.test(name)) return { type: "string" };
1578
+ if (Object.hasOwn(globals, name)) return globals[name];
1579
+ if (element !== void 0 && Object.hasOwn(element.attributes, name)) return element.attributes[name];
1580
+ };
1581
+ const spec = attributeSpec();
1582
+ if (spec === void 0) return {
1583
+ text: void 0,
1584
+ diagnostics: sink.diagnostics
1585
+ };
1586
+ return {
1587
+ text: attributeValue(sink, tag, name, spec, value, origins),
1588
+ diagnostics: sink.diagnostics
1589
+ };
1590
+ };
1591
+ //#endregion
1592
+ //#region src/email/components.ts
1593
+ /** Marks the hidden preview block so the renderer can read it back and the text lane can keep it. */
1594
+ const PREHEADER_ATTRIBUTE = "data-samva-preheader";
1595
+ const PREHEADER_PADDING_ATTRIBUTE = "data-samva-preheader-padding";
1596
+ const PREHEADER_TARGET_LENGTH = 200;
1597
+ const PREHEADER_PADDING = "\xA0‌​‍‎‏";
1598
+ const presentation = (table, row, rowProps = {}) => jsx("table", {
1599
+ role: "presentation",
1600
+ border: 0,
1601
+ cellpadding: 0,
1602
+ cellspacing: 0,
1603
+ ...table,
1604
+ children: jsx("tbody", { children: jsx("tr", {
1605
+ ...rowProps,
1606
+ children: row
1607
+ }) })
1608
+ });
1609
+ /** The hidden inbox preview block. Padding characters stop the client borrowing body copy. */
1610
+ const preheaderNode = (text) => jsx("div", {
1611
+ [PREHEADER_ATTRIBUTE]: text,
1612
+ style: {
1613
+ display: "none",
1614
+ overflow: "hidden",
1615
+ lineHeight: "1px",
1616
+ opacity: 0,
1617
+ maxHeight: 0,
1618
+ maxWidth: 0
1619
+ },
1620
+ children: [text, text.length >= PREHEADER_TARGET_LENGTH ? null : jsx("div", {
1621
+ [PREHEADER_PADDING_ATTRIBUTE]: true,
1622
+ children: PREHEADER_PADDING.repeat(Math.ceil((PREHEADER_TARGET_LENGTH - text.length) / 7))
1623
+ })]
1624
+ });
1625
+ /** Document shell: the head every target client needs, plus the body content cell. */
1626
+ const Email = ({ className, lang = "en", dir = "ltr", title, backgroundColor, style, children }) => jsx("html", {
1627
+ lang,
1628
+ dir,
1629
+ xmlns: "http://www.w3.org/1999/xhtml",
1630
+ "xmlns:o": "urn:schemas-microsoft-com:office:office",
1631
+ children: [jsx("head", { children: [
1632
+ jsx("meta", { charset: "utf-8" }),
1633
+ jsx("meta", {
1634
+ "http-equiv": "Content-Type",
1635
+ content: "text/html; charset=UTF-8"
1636
+ }),
1637
+ jsx("meta", {
1638
+ name: "viewport",
1639
+ content: "width=device-width, initial-scale=1"
1640
+ }),
1641
+ jsx("meta", { name: "x-apple-disable-message-reformatting" }),
1642
+ jsx("meta", {
1643
+ name: "color-scheme",
1644
+ content: "light dark"
1645
+ }),
1646
+ jsx("meta", {
1647
+ name: "supported-color-schemes",
1648
+ content: "light dark"
1649
+ }),
1650
+ title === void 0 ? null : jsx("title", { children: title }),
1651
+ rawMarkup("<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->")
1652
+ ] }), jsx("body", {
1653
+ style: {
1654
+ margin: 0,
1655
+ padding: 0,
1656
+ ...backgroundColor === void 0 ? {} : { backgroundColor }
1657
+ },
1658
+ children: [presentation({
1659
+ align: "center",
1660
+ width: "100%",
1661
+ ...backgroundColor === void 0 ? {} : { bgcolor: backgroundColor }
1662
+ }, jsx("td", {
1663
+ className,
1664
+ ...style === void 0 ? {} : { style },
1665
+ children
1666
+ }))]
1667
+ })]
1668
+ });
1669
+ /** A full-width band of content, as the single-cell table every client agrees on. */
1670
+ const Section = ({ className, align = "center", width = "100%", bgcolor, style, tableStyle, children }) => presentation({
1671
+ align,
1672
+ width,
1673
+ ...bgcolor === void 0 ? {} : { bgcolor },
1674
+ ...tableStyle === void 0 ? {} : { style: tableStyle }
1675
+ }, jsx("td", {
1676
+ className,
1677
+ ...style === void 0 ? {} : { style },
1678
+ children
1679
+ }));
1680
+ /** The equal share of a row that each of `count` cells takes, whole percent rounded down. */
1681
+ const equalColumnWidth = (count) => `${Math.floor(100 / count)}%`;
1682
+ /** Give a `Column` that states no width its equal share, keeping the primitive's attribute order. */
1683
+ const withColumnWidth = (node, width) => {
1684
+ if (node.type === "Fragment") return EmailNode.Fragment({ children: node.children.map((child) => withColumnWidth(child, width)) });
1685
+ if (node.type !== "Element" || node.tag !== "td" || node.props.width !== void 0) return node;
1686
+ const props = {};
1687
+ for (const [key, value] of Object.entries(node.props)) {
1688
+ if (key === "style") props.width = width;
1689
+ props[key] = value;
1690
+ }
1691
+ if (props.width === void 0) props.width = width;
1692
+ return EmailNode.Element({
1693
+ ...node,
1694
+ props
1695
+ });
1696
+ };
1697
+ /**
1698
+ * A row of side-by-side `Column`s. No flex or grid conversion happens here. With `each` it repeats
1699
+ * its cells over a list, `per` to a row, and splits the row equally between cells that state no
1700
+ * width.
1701
+ */
1702
+ const Columns = ({ className, align = "center", width = "100%", style, each, per, children }) => {
1703
+ const table = {
1704
+ className,
1705
+ align,
1706
+ width,
1707
+ ...style === void 0 ? {} : { style }
1708
+ };
1709
+ if (each === void 0 || typeof children !== "function") return presentation(table, children, { style: { width: "100%" } });
1710
+ if (per !== void 0 && (!Number.isInteger(per) || per < 1)) reportEmailDiagnostic({
1711
+ code: "invalid-columns-per",
1712
+ severity: "error",
1713
+ message: "<Columns per> must be a positive integer."
1714
+ });
1715
+ const size = per !== void 0 && Number.isInteger(per) && per >= 1 ? per : void 0;
1716
+ const chunks = [];
1717
+ if (size === void 0) chunks.push(each);
1718
+ else for (let at = 0; at < each.length; at += size) chunks.push(each.slice(at, at + size));
1719
+ const share = equalColumnWidth(size ?? Math.max(each.length, 1));
1720
+ return jsx(Fragment, { children: chunks.map((chunk) => presentation(table, chunk.flatMap((item, index) => normalizeChildren(children(item, index)).map((cell) => withColumnWidth(cell, share))), { style: { width: "100%" } })) });
1721
+ };
1722
+ /** One cell of a `Columns` row. */
1723
+ const Column = ({ className, align, valign, width, style, children }) => jsx("td", {
1724
+ className,
1725
+ ...align === void 0 ? {} : { align },
1726
+ ...valign === void 0 ? {} : { valign },
1727
+ ...width === void 0 ? {} : { width },
1728
+ ...style === void 0 ? {} : { style },
1729
+ children
1730
+ });
1731
+ const DEFAULT_BUTTON_FONT = "Helvetica, Arial, sans-serif";
1732
+ /** Average advance of a bold sans-serif glyph, as a fraction of the font size. */
1733
+ const BOLD_GLYPH_ADVANCE = .6;
1734
+ const invalidButtonNumber = (name) => reportEmailDiagnostic({
1735
+ code: "invalid-button-number",
1736
+ severity: "error",
1737
+ message: `<Button ${name}> must be a finite number.`
1738
+ });
1739
+ const buttonNumber = (name, value, fallback) => {
1740
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1741
+ invalidButtonNumber(name);
1742
+ return fallback;
1743
+ };
1744
+ const optionalButtonNumber = (name, value) => {
1745
+ if (value === void 0) return void 0;
1746
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1747
+ invalidButtonNumber(name);
1748
+ };
1749
+ const optionalPositiveButtonNumber = (name, value) => {
1750
+ if (value === void 0) return void 0;
1751
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
1752
+ reportEmailDiagnostic({
1753
+ code: "invalid-button-number",
1754
+ severity: "error",
1755
+ message: `<Button ${name}> must be a positive finite number.`
1756
+ });
1757
+ };
1758
+ /** A link styled as a button, with a VML fallback so classic Outlook draws the shape. */
1759
+ const Button = ({ className, href, width, height, paddingX = 16, backgroundColor = "#000000", color = "#ffffff", borderRadius = 0, borderColor, borderWidth = 0, fontSize = 16, fontFamily = DEFAULT_BUTTON_FONT, align = "left", style, children }) => {
1760
+ const safeWidth = optionalButtonNumber("width", width);
1761
+ const safeHeight = optionalPositiveButtonNumber("height", height);
1762
+ const safePaddingX = buttonNumber("paddingX", paddingX, 16);
1763
+ const safeBorderRadius = buttonNumber("borderRadius", borderRadius, 0);
1764
+ const safeBorderWidth = buttonNumber("borderWidth", borderWidth, 0);
1765
+ const safeFontSize = buttonNumber("fontSize", fontSize, 16);
1766
+ const label = typeof children === "string" ? children : void 0;
1767
+ const fallback = safeHeight !== void 0 && label !== void 0;
1768
+ if (!fallback) reportEmailDiagnostic({
1769
+ code: "button-outlook-fallback",
1770
+ severity: "warning",
1771
+ 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.",
1772
+ clients: ["outlook-windows"]
1773
+ });
1774
+ const arcsize = safeHeight === void 0 ? 0 : Math.round(safeBorderRadius / safeHeight * 100);
1775
+ const fallbackWidth = safeWidth ?? Math.ceil((label?.length ?? 0) * safeFontSize * BOLD_GLYPH_ADVANCE) + 2 * safePaddingX;
1776
+ return presentation({
1777
+ width: "100%",
1778
+ style: { borderCollapse: "collapse" }
1779
+ }, jsx("td", {
1780
+ align,
1781
+ 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(backgroundColor)}"><w:anchorlock/><center style="color:${escapeAttribute(color)};font-family:${escapeAttribute(fontFamily)};font-size:${safeFontSize}px;font-weight:bold;">${escapeText(label)}</center></v:roundrect><![endif]-->`) : null, jsx("a", {
1782
+ className,
1783
+ href,
1784
+ target: "_blank",
1785
+ rel: "noopener noreferrer",
1786
+ style: {
1787
+ backgroundColor,
1788
+ color,
1789
+ borderRadius: `${safeBorderRadius}px`,
1790
+ display: "inline-block",
1791
+ fontFamily,
1792
+ fontSize: `${safeFontSize}px`,
1793
+ fontWeight: "bold",
1794
+ lineHeight: safeHeight === void 0 ? "120%" : `${safeHeight - 2 * safeBorderWidth}px`,
1795
+ textAlign: "center",
1796
+ textDecoration: "none",
1797
+ ...safeWidth === void 0 ? { padding: `0 ${safePaddingX}px` } : {
1798
+ width: `${safeWidth}px`,
1799
+ maxWidth: "100%"
1800
+ },
1801
+ ...borderColor === void 0 ? {} : { border: `${safeBorderWidth}px solid ${borderColor}` },
1802
+ ...fallback ? { msoHide: "all" } : {},
1803
+ ...style
1804
+ },
1805
+ children
1806
+ })]
1807
+ }));
1808
+ };
1809
+ /** Vertical space that survives clients which collapse margins. */
1810
+ const Spacer = ({ height }) => presentation({ width: "100%" }, jsx("td", {
1811
+ height,
1812
+ style: {
1813
+ height: `${height}px`,
1814
+ lineHeight: `${height}px`,
1815
+ fontSize: "1px"
1816
+ },
1817
+ children: "​"
1818
+ }));
1819
+ /** A horizontal rule drawn as a cell border, which every target client renders. */
1820
+ const Divider = ({ className, color = "#e5e7eb", thickness = 1, style }) => presentation({ width: "100%" }, jsx("td", {
1821
+ className,
1822
+ style: {
1823
+ borderTop: `${thickness}px solid ${color}`,
1824
+ fontSize: "1px",
1825
+ lineHeight: "1px",
1826
+ ...style
1827
+ },
1828
+ children: "​"
1829
+ }));
1830
+ /** An image with the width/height attributes Outlook sizes from before CSS applies. */
1831
+ const Image = ({ className, src, alt, width, height, align, style }) => jsx("img", {
1832
+ className,
1833
+ src,
1834
+ alt,
1835
+ border: 0,
1836
+ ...width === void 0 ? {} : { width },
1837
+ ...height === void 0 ? {} : { height },
1838
+ ...align === void 0 ? {} : { align },
1839
+ style: {
1840
+ display: "block",
1841
+ outline: "none",
1842
+ border: "none",
1843
+ textDecoration: "none",
1844
+ maxWidth: "100%",
1845
+ ...style
1846
+ }
1847
+ });
1848
+ /** A text link that opens outside the client and cannot reach back at the opener. */
1849
+ const Link = ({ className, href, target = "_blank", rel = "noopener noreferrer", style, children }) => jsx("a", {
1850
+ className,
1851
+ href,
1852
+ target,
1853
+ rel,
1854
+ style: {
1855
+ color: "#067df7",
1856
+ textDecoration: "none",
1857
+ ...style
1858
+ },
1859
+ children
1860
+ });
1861
+ //#endregion
1862
+ export { renderIrSms as A, emailSelectionAncestry as C, renderIrPreview as D, emailSelections as E, isUrlAttribute as F, EMAIL_ELEMENTS as M, EMAIL_GLOBAL_ATTRIBUTES as N, IrRenderError as O, emailElement as P, emailDefinitionInstances as S, emailSelectionSiblings as T, kebab as _, Email as a, INSTANCE_PATH_ATTRIBUTE as b, PREHEADER_ATTRIBUTE as c, equalColumnWidth as d, preheaderNode as f, escapeText as g, escapeAttribute as h, Divider as i, renderIrWhatsApp as j, renderIr as k, Section as l, declarationValue as m, Column as n, Image as o, checkAttribute as p, Columns as r, Link as s, Button as t, Spacer as u, serializeEmailHtml as v, emailSelectionIndex as w, emailAuthoringTarget as x, serializeStyle as y };
1863
+
1864
+ //# sourceMappingURL=components-BZCqegS8.js.map