@samva/markup 0.16.0 → 0.17.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 (60) hide show
  1. package/AGENTS.md +42 -40
  2. package/CHANGELOG.md +72 -0
  3. package/README.md +10 -9
  4. package/dist/{brand-jFV6YtSV.d.ts → assets-CS5Xa_mr.d.ts} +41 -3
  5. package/dist/{brand-Ci05p37c.js → brand-CIiEqkU7.js} +64 -3
  6. package/dist/{brand-plugin-CHhcei-j.d.ts → brand-plugin-Dj9SMpfS.d.ts} +3 -1
  7. package/dist/brand.d.ts +2 -3
  8. package/dist/brand.js +3 -4
  9. package/dist/compatibility.d.ts +76 -0
  10. package/dist/compatibility.js +807 -0
  11. package/dist/compiled-template-Bc4Dq9zy.d.ts +268 -0
  12. package/dist/compiler.d.ts +59 -193
  13. package/dist/compiler.js +473 -775
  14. package/dist/{components-3XHjYD3i.js → components-C5850-Qg.js} +37 -29
  15. package/dist/css-B_aVKrDo.js +436 -0
  16. package/dist/css-color-DLM12Osu.js +315 -0
  17. package/dist/{diagnostic-codes-8XDLR9ef.js → diagnostic-codes-DZKb0Xcg.js} +40 -1
  18. package/dist/{diagnostics-CUYlQ_6x.d.ts → diagnostics-D4vSLQrO.d.ts} +1 -10
  19. package/dist/diagnostics.d.ts +6 -2
  20. package/dist/diagnostics.js +3 -4
  21. package/dist/edit.d.ts +10 -9
  22. package/dist/edit.js +12 -11
  23. package/dist/email/jsx-dev-runtime.d.ts +1 -1
  24. package/dist/email/jsx-dev-runtime.js +1 -1
  25. package/dist/email/jsx-runtime.d.ts +2 -2
  26. package/dist/email/jsx-runtime.js +2 -2
  27. package/dist/email.d.ts +1 -1
  28. package/dist/email.js +1 -1
  29. package/dist/html-DJUK2YJF.js +483 -0
  30. package/dist/index.d.ts +1 -1
  31. package/dist/{jsx-runtime-DC7xG0aA.d.ts → jsx-runtime-DL8NVF1f.d.ts} +12 -6
  32. package/dist/{jsx-runtime-CBlexoeR.js → jsx-runtime-qL19u3Fy.js} +17 -10
  33. package/dist/preview-rNoJBEIY.d.ts +104 -0
  34. package/dist/{profile-BsY4dkan.js → profile-JTvgCD6X.js} +714 -29
  35. package/dist/{html-BvRFepeO.js → render-template-DwPDm3Hq.js} +155 -572
  36. package/dist/render.d.ts +40 -28
  37. package/dist/render.js +3 -47
  38. package/dist/selectors-CYWCoxZr.js +157 -0
  39. package/dist/sms.d.ts +1 -1
  40. package/dist/sms.js +1 -1
  41. package/dist/{template-B-UDXK8a.d.ts → template-DmNcoH4Q.d.ts} +2 -9
  42. package/dist/{theme-css-s9QwD5Ko.js → theme-css-P9F5S7Ic.js} +3 -316
  43. package/dist/whatsapp.d.ts +1 -1
  44. package/dist/whatsapp.js +1 -1
  45. package/generated/contract.json +7 -2
  46. package/generated/reference.md +3 -2
  47. package/package.json +5 -5
  48. package/skills/sml/SKILL.md +2 -2
  49. package/dist/assets-BSpgZOSR.js +0 -61
  50. package/dist/assets-BSuNT9gr.d.ts +0 -39
  51. package/dist/brand-footer-ClRtWnIc.d.ts +0 -40
  52. package/dist/brand-footer-DXVGsKE5.js +0 -746
  53. package/dist/brand-sample.d.ts +0 -4
  54. package/dist/brand-sample.js +0 -2
  55. package/dist/cascade-DoLWZ66g.js +0 -701
  56. package/dist/diagnostic-model-DJtqbETd.js +0 -36
  57. package/dist/ir-KJLFW08u.js +0 -5
  58. package/dist/render-CVnXZMyK.js +0 -258
  59. package/dist/render-CuZej25A.d.ts +0 -564
  60. package/dist/theme-imports-u_P0ygvQ.js +0 -102
@@ -1,19 +1,8 @@
1
- import { n as isObject } from "./guards-eV1qLbf2.js";
2
1
  import { callFormatter, createFmt } from "./fmt.js";
3
- //#region src/internal/unsubscribe-link.ts
4
- /**
5
- * Marks the one link the serializer lets carry the brand plugin's unsubscribe
6
- * placeholder as its href. Only `BrandFooter` sets it; nothing public exports
7
- * it, so no author-written link can claim the exemption.
8
- */
9
- const UNSUBSCRIBE_LINK = Symbol("samva.unsubscribe-link");
10
- /**
11
- * Marks the elements `BrandFooter` writes the brand plugin's footer markers
12
- * on. The serializer emits those reserved attributes only for an element
13
- * carrying it, so an author cannot claim a footer the send path would then
14
- * not append.
15
- */
16
- const BRAND_FOOTER_MARKER = Symbol("samva.brand-footer-marker");
2
+ import { TaggedError } from "better-result";
3
+ //#region src/compiled-template.ts
4
+ /** The version this package writes. A publication pins the version it was built with. */
5
+ const COMPILED_TEMPLATE_VERSION = 1;
17
6
  //#endregion
18
7
  //#region src/email/samva-brand-plugin.ts
19
8
  /**
@@ -36,7 +25,8 @@ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
36
25
  /** `<specifier>` or `<specifier>/<slug>`, as a regular expression source with no groups. */
37
26
  const brandSpecifierPattern = (plugin) => `${escapeRegExp(plugin.specifier)}(?:\\/${SLUG})?`;
38
27
  /** The brand a specifier names: `undefined` slug means the default brand; `undefined` when it names none. */
39
- const parseBrandSpecifier = (specifier, plugin = samvaBrandPlugin) => {
28
+ const parseBrandSpecifier = (specifier, options = {}) => {
29
+ const plugin = options.brandPlugin ?? samvaBrandPlugin;
40
30
  const match = new RegExp(`^${escapeRegExp(plugin.specifier)}(?:\\/(${SLUG}))?$`).exec(specifier);
41
31
  return match === null ? void 0 : { slug: match[1] };
42
32
  };
@@ -343,20 +333,61 @@ const isUrlAttribute = (tag, attribute) => {
343
333
  /** Look up one supported element, or `undefined` when the tag is outside the vocabulary. */
344
334
  const emailElement = (tag) => Object.hasOwn(EMAIL_ELEMENTS, tag) ? EMAIL_ELEMENTS[tag] : void 0;
345
335
  //#endregion
346
- //#region src/render-ir.ts
347
- var IrRenderError = class extends Error {
348
- diagnostics;
349
- constructor(diagnostics) {
350
- super(`Template render rejected: ${diagnostics.map((item) => `${item.code}: ${item.message}`).join("; ")}`);
351
- this.name = "IrRenderError";
352
- this.diagnostics = diagnostics;
336
+ //#region src/sms-segments.ts
337
+ const GSM7_EXTENDED = /* @__PURE__ */ new Set([
338
+ "[",
339
+ "]",
340
+ "{",
341
+ "}",
342
+ "\\",
343
+ "~",
344
+ "|",
345
+ "€",
346
+ "^",
347
+ "\f"
348
+ ]);
349
+ const GSM7_BASIC_PATTERN = /^[@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&'()*+,\-./:;<=>?¡0-9ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà]*$/;
350
+ const containsNonGsmCharacters = (text) => {
351
+ for (const char of text) {
352
+ if (GSM7_EXTENDED.has(char)) continue;
353
+ if (!GSM7_BASIC_PATTERN.test(char)) return true;
354
+ }
355
+ return false;
356
+ };
357
+ const smsSegmentInfo = (text) => {
358
+ if (containsNonGsmCharacters(text)) {
359
+ const codePoints = [...text].length;
360
+ const segmentCount = codePoints <= 70 ? 1 : Math.ceil(codePoints / 67);
361
+ return {
362
+ encoding: "ucs2",
363
+ characterCount: text.length,
364
+ segmentCount
365
+ };
366
+ }
367
+ let encodedLength = 0;
368
+ for (const char of text) encodedLength += GSM7_EXTENDED.has(char) ? 2 : 1;
369
+ const segmentCount = encodedLength <= 160 ? 1 : Math.ceil(encodedLength / 153);
370
+ return {
371
+ encoding: "gsm7",
372
+ characterCount: text.length,
373
+ segmentCount
374
+ };
375
+ };
376
+ //#endregion
377
+ //#region src/render-template.ts
378
+ var RenderError = class extends TaggedError("RenderError") {
379
+ constructor(args) {
380
+ super({
381
+ diagnostics: args.diagnostics,
382
+ message: `Template render rejected: ${args.diagnostics.map((item) => `${item.code}: ${item.message}`).join("; ")}`
383
+ });
353
384
  }
354
385
  };
355
386
  const fail = (code, message) => {
356
- throw new IrRenderError([{
387
+ throw new RenderError({ diagnostics: [{
357
388
  code,
358
389
  message
359
- }]);
390
+ }] });
360
391
  };
361
392
  const MAX_ITERATIONS = 1e4;
362
393
  const MAX_DEPTH = 64;
@@ -364,27 +395,27 @@ const MAX_DEPTH = 64;
364
395
  const EMAIL_DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
365
396
  const PREHEADER_TARGET_LENGTH = 200;
366
397
  const PREHEADER_PADDING = "\xA0‌​‍‎‏";
367
- const escapeText$1 = (value) => value.replace(/[&<>]/g, (character) => ({
398
+ const escapeText = (value) => value.replace(/[&<>]/g, (character) => ({
368
399
  "&": "&amp;",
369
400
  "<": "&lt;",
370
401
  ">": "&gt;"
371
402
  })[character] ?? character);
372
- const escapeAttribute$1 = (value) => value.replace(/[&<>"']/g, (character) => ({
403
+ const escapeAttribute = (value) => value.replace(/[&<>"']/g, (character) => ({
373
404
  "&": "&amp;",
374
405
  "<": "&lt;",
375
406
  ">": "&gt;",
376
407
  "\"": "&quot;",
377
408
  "'": "&#39;"
378
409
  })[character] ?? character);
379
- const CONTROL_CHARACTERS$1 = /[\u0000-\u001f\u007f]/;
380
- const SAFE_URL_SCHEME$1 = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
410
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
411
+ const SAFE_URL_SCHEME = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
381
412
  /** A delivered message can resolve only an absolute URL, so relative references are refused. */
382
- const isSafeUrl$1 = (value) => !/^[\u0000- ]/.test(value) && !CONTROL_CHARACTERS$1.test(value) && !value.includes("\\") && SAFE_URL_SCHEME$1.test(value);
383
- const CSS_PROPERTY$1 = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
384
- const CSS_UNSAFE$1 = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
385
- const CSS_URL$1 = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
386
- const kebab$1 = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
387
- const UNITLESS$1 = /* @__PURE__ */ new Set([
413
+ const isSafeUrl = (value) => !/^[\u0000- ]/.test(value) && !CONTROL_CHARACTERS.test(value) && !value.includes("\\") && SAFE_URL_SCHEME.test(value);
414
+ const CSS_PROPERTY = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
415
+ const CSS_UNSAFE = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
416
+ const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
417
+ const kebab = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
418
+ const UNITLESS = /* @__PURE__ */ new Set([
388
419
  "opacity",
389
420
  "zIndex",
390
421
  "fontWeight",
@@ -399,8 +430,8 @@ const UNITLESS$1 = /* @__PURE__ */ new Set([
399
430
  "fillOpacity",
400
431
  "strokeOpacity"
401
432
  ]);
402
- const declarationValue$1 = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS$1.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
403
- const report$1 = (ctx, code, message) => {
433
+ const declarationValue = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
434
+ const report = (ctx, code, message) => {
404
435
  ctx.diagnostics.push({
405
436
  code,
406
437
  message
@@ -541,79 +572,79 @@ const evalPredicate = (ctx, scope, predicate, depth) => {
541
572
  return invalid("Unknown predicate.");
542
573
  };
543
574
  const asArray = (value) => Array.isArray(value) ? value : invalid("Expected an array.");
544
- const declarations$1 = (ctx, scope, entries, depth) => {
575
+ const declarations = (ctx, scope, entries, depth) => {
545
576
  const parts = [];
546
577
  for (const entry of entries) {
547
578
  if (!Array.isArray(entry) || entry.length !== 2) {
548
- report$1(ctx, "invalid-style", "A style declaration is a [property, value] pair.");
579
+ report(ctx, "invalid-style", "A style declaration is a [property, value] pair.");
549
580
  continue;
550
581
  }
551
582
  const [property, raw] = entry;
552
583
  const value = evalValue(ctx, scope, raw, depth + 1);
553
584
  if (value === void 0 || value === null) continue;
554
- if (typeof property !== "string" || !CSS_PROPERTY$1.test(property)) {
555
- report$1(ctx, "unsupported-style-property", `Style property ${JSON.stringify(property)} is not a CSS property name.`);
585
+ if (typeof property !== "string" || !CSS_PROPERTY.test(property)) {
586
+ report(ctx, "unsupported-style-property", `Style property ${JSON.stringify(property)} is not a CSS property name.`);
556
587
  continue;
557
588
  }
558
589
  if (typeof value !== "string" && !(typeof value === "number" && Number.isFinite(value))) {
559
- report$1(ctx, "invalid-style-value", `Style ${property} must be a string or finite number.`);
590
+ report(ctx, "invalid-style-value", `Style ${property} must be a string or finite number.`);
560
591
  continue;
561
592
  }
562
- const text = declarationValue$1(property, value);
563
- if (CSS_UNSAFE$1.test(text) || CONTROL_CHARACTERS$1.test(text)) {
564
- report$1(ctx, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration.`);
593
+ const text = declarationValue(property, value);
594
+ if (CSS_UNSAFE.test(text) || CONTROL_CHARACTERS.test(text)) {
595
+ report(ctx, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration.`);
565
596
  continue;
566
597
  }
567
598
  let unsafeUrl;
568
- for (const match of text.matchAll(CSS_URL$1)) if (!isSafeUrl$1(match[2] ?? "")) unsafeUrl = match[2] ?? "";
599
+ for (const match of text.matchAll(CSS_URL)) if (!isSafeUrl(match[2] ?? "")) unsafeUrl = match[2] ?? "";
569
600
  if (unsafeUrl !== void 0) {
570
- report$1(ctx, "unsafe-url", `Style ${property} points at ${JSON.stringify(unsafeUrl)}; email URLs must use https:, mailto:, tel:, cid: or a fragment.`);
601
+ report(ctx, "unsafe-url", `Style ${property} points at ${JSON.stringify(unsafeUrl)}; email URLs must use https:, mailto:, tel:, cid: or a fragment.`);
571
602
  continue;
572
603
  }
573
- parts.push(`${kebab$1(property)}:${text}`);
604
+ parts.push(`${kebab(property)}:${text}`);
574
605
  }
575
606
  return parts.join(";");
576
607
  };
577
608
  const styleText = (ctx, scope, style, depth) => {
578
609
  checkDepth(depth);
579
610
  if (typeof style === "string") {
580
- if (CONTROL_CHARACTERS$1.test(style)) {
581
- report$1(ctx, "unsafe-style-value", "A style contains control characters.");
611
+ if (CONTROL_CHARACTERS.test(style)) {
612
+ report(ctx, "unsafe-style-value", "A style contains control characters.");
582
613
  return "";
583
614
  }
584
615
  return style;
585
616
  }
586
- if (Array.isArray(style)) return declarations$1(ctx, scope, style, depth);
617
+ if (Array.isArray(style)) return declarations(ctx, scope, style, depth);
587
618
  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);
588
- report$1(ctx, "invalid-style", "An element style is a string, a declaration list or a conditional.");
619
+ report(ctx, "invalid-style", "An element style is a string, a declaration list or a conditional.");
589
620
  return "";
590
621
  };
591
622
  const attributeText = (ctx, tag, name, spec, value) => {
592
623
  if (value === void 0 || value === null || value === false) return void 0;
593
624
  if (value === true) return "";
594
625
  if (typeof value !== "string" && !(typeof value === "number" && Number.isFinite(value))) {
595
- report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a string or finite number, not ${typeof value}.`);
626
+ report(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a string or finite number, not ${typeof value}.`);
596
627
  return;
597
628
  }
598
629
  const text = String(value);
599
- if (CONTROL_CHARACTERS$1.test(text)) {
600
- report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`);
630
+ if (CONTROL_CHARACTERS.test(text)) {
631
+ report(ctx, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`);
601
632
  return;
602
633
  }
603
634
  if (spec.values !== void 0 && !spec.values.includes(text)) {
604
- report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`);
635
+ report(ctx, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`);
605
636
  return;
606
637
  }
607
638
  if (spec.type === "number" && !/^-?\d+$/.test(text)) {
608
- report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`);
639
+ report(ctx, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`);
609
640
  return;
610
641
  }
611
642
  if (spec.type === "measure" && !/^\d+(\.\d+)?%?$/.test(text)) {
612
- report$1(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`);
643
+ report(ctx, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`);
613
644
  return;
614
645
  }
615
- if (spec.type === "url" && !isSafeUrl$1(text)) {
616
- 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.`);
646
+ if (spec.type === "url" && !isSafeUrl(text)) {
647
+ report(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.`);
617
648
  return;
618
649
  }
619
650
  return text;
@@ -624,7 +655,7 @@ const buildAttributes = (ctx, scope, tag, spec, node, props, depth) => {
624
655
  let classes;
625
656
  const source = node.attrs ?? {};
626
657
  if (!isRecord(source)) {
627
- report$1(ctx, "invalid-attribute-value", `<${tag}> attrs must be an object.`);
658
+ report(ctx, "invalid-attribute-value", `<${tag}> attrs must be an object.`);
628
659
  return "";
629
660
  }
630
661
  for (const [key, raw] of Object.entries(source)) {
@@ -634,78 +665,78 @@ const buildAttributes = (ctx, scope, tag, spec, node, props, depth) => {
634
665
  props.set(name, value);
635
666
  if (tag === "a" && name === "href" && raw === ctx.brandPlugin.unsubscribeUrlPlaceholder) {
636
667
  present.add(name);
637
- emitted.push(` href="${escapeAttribute$1(ctx.brandPlugin.unsubscribeUrlPlaceholder)}" ${ctx.brandPlugin.noTrackAttribute}`);
668
+ emitted.push(` href="${escapeAttribute(ctx.brandPlugin.unsubscribeUrlPlaceholder)}" ${ctx.brandPlugin.noTrackAttribute}`);
638
669
  continue;
639
670
  }
640
671
  if (name === "class") {
641
672
  if (value === void 0 || value === null) continue;
642
673
  if (typeof value !== "string") {
643
- report$1(ctx, "invalid-attribute-value", `<${tag} class> must be a string.`);
674
+ report(ctx, "invalid-attribute-value", `<${tag} class> must be a string.`);
644
675
  continue;
645
676
  }
646
- if (CONTROL_CHARACTERS$1.test(value)) {
647
- report$1(ctx, "invalid-attribute-value", `<${tag} class> contains control characters.`);
677
+ if (CONTROL_CHARACTERS.test(value)) {
678
+ report(ctx, "invalid-attribute-value", `<${tag} class> contains control characters.`);
648
679
  continue;
649
680
  }
650
681
  if (classes !== void 0) {
651
- report$1(ctx, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`);
682
+ report(ctx, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`);
652
683
  continue;
653
684
  }
654
685
  classes = value;
655
- emitted.push(` class="${escapeAttribute$1(value)}"`);
686
+ emitted.push(` class="${escapeAttribute(value)}"`);
656
687
  continue;
657
688
  }
658
689
  if (name === "style") {
659
- report$1(ctx, "invalid-style", `<${tag}> writes its style on the element, not as an attribute.`);
690
+ report(ctx, "invalid-style", `<${tag}> writes its style on the element, not as an attribute.`);
660
691
  continue;
661
692
  }
662
693
  if (/^on/i.test(name)) {
663
- report$1(ctx, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`);
694
+ report(ctx, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`);
664
695
  continue;
665
696
  }
666
697
  if (ctx.reservedAttributes.has(name) && node.reserved !== true) {
667
- report$1(ctx, "reserved-attribute", `<${tag} ${name}> is reserved for the brand footer.`);
698
+ report(ctx, "reserved-attribute", `<${tag} ${name}> is reserved for the brand footer.`);
668
699
  continue;
669
700
  }
670
701
  if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
671
702
  const text = attributeText(ctx, tag, name, { type: "string" }, value);
672
- if (text !== void 0) emitted.push(` ${name}="${escapeAttribute$1(text)}"`);
703
+ if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
673
704
  continue;
674
705
  }
675
706
  const globals = EMAIL_GLOBAL_ATTRIBUTES;
676
707
  const attribute = Object.hasOwn(globals, name) ? globals[name] : Object.hasOwn(spec.attributes, name) ? spec.attributes[name] : void 0;
677
708
  if (attribute === void 0) {
678
- report$1(ctx, "unsupported-attribute", `<${tag}> does not support the ${name} attribute.`);
709
+ report(ctx, "unsupported-attribute", `<${tag}> does not support the ${name} attribute.`);
679
710
  continue;
680
711
  }
681
712
  present.add(name);
682
713
  const text = attributeText(ctx, tag, name, attribute, value);
683
- if (text !== void 0) emitted.push(` ${name}="${escapeAttribute$1(text)}"`);
714
+ if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
684
715
  }
685
- 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.`);
716
+ for (const [name, attribute] of Object.entries(spec.attributes)) if (attribute.required === true && !present.has(name)) report(ctx, "missing-attribute", `<${tag}> requires the ${name} attribute.`);
686
717
  if (node.style !== void 0) {
687
718
  const css = styleText(ctx, scope, node.style, depth + 1);
688
- if (css !== "") emitted.push(` style="${escapeAttribute$1(css)}"`);
719
+ if (css !== "") emitted.push(` style="${escapeAttribute(css)}"`);
689
720
  }
690
721
  return emitted.join("");
691
722
  };
692
- const checkContentModel$1 = (ctx, tag, spec, children) => {
723
+ const checkContentModel = (ctx, tag, spec, children) => {
693
724
  if (spec.content === "void") {
694
- if (children.length > 0) report$1(ctx, "void-element-children", `<${tag}> cannot have children.`);
725
+ if (children.length > 0) report(ctx, "void-element-children", `<${tag}> cannot have children.`);
695
726
  return;
696
727
  }
697
728
  if (spec.content === "text" || spec.content === "raw") {
698
- for (const child of children) if (child.t !== "text") report$1(ctx, "invalid-content", `<${tag}> accepts text only.`);
729
+ for (const child of children) if (child.t !== "text") report(ctx, "invalid-content", `<${tag}> accepts text only.`);
699
730
  return;
700
731
  }
701
732
  if (spec.childElements === void 0) return;
702
733
  for (const child of children) {
703
734
  if (child.t === "raw") continue;
704
735
  if (child.t === "text") {
705
- if (child.v.trim() !== "") report$1(ctx, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`);
736
+ if (child.v.trim() !== "") report(ctx, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`);
706
737
  continue;
707
738
  }
708
- if (!spec.childElements.includes(child.tag)) report$1(ctx, "invalid-content", `<${tag}> accepts only ${spec.childElements.map((name) => `<${name}>`).join(", ")}; found <${child.tag}>.`);
739
+ if (!spec.childElements.includes(child.tag)) report(ctx, "invalid-content", `<${tag}> accepts only ${spec.childElements.map((name) => `<${name}>`).join(", ")}; found <${child.tag}>.`);
709
740
  }
710
741
  };
711
742
  const pushText = (out, value) => {
@@ -718,21 +749,21 @@ const pushText = (out, value) => {
718
749
  const evalElement = (ctx, scope, node, depth, out) => {
719
750
  const tag = node.el;
720
751
  if (ctx.plain) {
721
- report$1(ctx, "unsupported-element", "A plain-text body cannot contain elements.");
752
+ report(ctx, "unsupported-element", "A plain-text body cannot contain elements.");
722
753
  return;
723
754
  }
724
755
  const spec = typeof tag === "string" ? emailElement(tag) : void 0;
725
756
  if (spec === void 0) {
726
- report$1(ctx, "unsupported-element", `<${String(tag)}> is not part of the supported email vocabulary.`);
757
+ report(ctx, "unsupported-element", `<${String(tag)}> is not part of the supported email vocabulary.`);
727
758
  return;
728
759
  }
729
760
  const children = [];
730
761
  for (const child of node.children ?? []) evalNode(ctx, scope, child, depth + 1, children);
731
- checkContentModel$1(ctx, tag, spec, children);
762
+ checkContentModel(ctx, tag, spec, children);
732
763
  if (spec.content === "raw") {
733
764
  const css = children.map((child) => child.t === "text" ? child.v : "").join("");
734
765
  if (/<\/|<!--/i.test(css)) {
735
- report$1(ctx, "unsafe-raw-text", `<${tag}> content cannot contain markup.`);
766
+ report(ctx, "unsafe-raw-text", `<${tag}> content cannot contain markup.`);
736
767
  return;
737
768
  }
738
769
  }
@@ -768,8 +799,8 @@ const evalRaw = (ctx, scope, node, depth, out) => {
768
799
  const rawPart = (ctx, scope, part, depth) => {
769
800
  if (typeof part === "string") return part;
770
801
  const record = part;
771
- if (isRecord(record) && has(record, "attr")) return escapeAttribute$1(textOf(evalValue(ctx, scope, record.attr, depth + 1)));
772
- if (isRecord(record) && has(record, "text")) return escapeText$1(textOf(evalValue(ctx, scope, record.text, depth + 1)));
802
+ if (isRecord(record) && has(record, "attr")) return escapeAttribute(textOf(evalValue(ctx, scope, record.attr, depth + 1)));
803
+ if (isRecord(record) && has(record, "text")) return escapeText(textOf(evalValue(ctx, scope, record.text, depth + 1)));
773
804
  return invalid("A raw part is a string, {attr} or {text}.");
774
805
  };
775
806
  const loopKey = (name) => name.startsWith("$") ? name : `$${name}`;
@@ -875,9 +906,9 @@ const insertPreheader = (nodes, block) => {
875
906
  });
876
907
  }
877
908
  };
878
- const serialize$1 = (node) => {
909
+ const serialize = (node) => {
879
910
  switch (node.t) {
880
- case "text": return escapeText$1(node.v);
911
+ case "text": return escapeText(node.v);
881
912
  case "raw": return node.html;
882
913
  case "el":
883
914
  if (node.content === "raw") {
@@ -885,7 +916,7 @@ const serialize$1 = (node) => {
885
916
  return css === "" ? "" : `<${node.tag}${node.attrs}>${css}</${node.tag}>`;
886
917
  }
887
918
  if (node.content === "void") return `<${node.tag}${node.attrs} />`;
888
- return `<${node.tag}${node.attrs}>${node.children.map(serialize$1).join("")}</${node.tag}>`;
919
+ return `<${node.tag}${node.attrs}>${node.children.map(serialize).join("")}</${node.tag}>`;
889
920
  }
890
921
  };
891
922
  /** Serialize while recording where each element lands, for the same document `serialize` writes. */
@@ -895,7 +926,7 @@ const serializeWithPositions = (nodes, offset) => {
895
926
  const write = (node) => {
896
927
  switch (node.t) {
897
928
  case "text": {
898
- const out = escapeText$1(node.v);
929
+ const out = escapeText(node.v);
899
930
  written += out.length;
900
931
  return out;
901
932
  }
@@ -1019,11 +1050,12 @@ const derive = (node, context) => {
1019
1050
  }
1020
1051
  };
1021
1052
  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();
1022
- const createContext = (ir, input, options, plain) => {
1053
+ const createContext = (template, input, options, plain) => {
1054
+ if (template.sml !== 1) fail("unsupported-version", `The template was compiled as version ${String(template.sml)}; this renderer reads version 1.`);
1023
1055
  const brandPlugin = options.brandPlugin ?? samvaBrandPlugin;
1024
1056
  return {
1025
1057
  input,
1026
- locale: options.locale ?? ir.locale,
1058
+ locale: options.locale ?? template.locale,
1027
1059
  timeZone: options.timeZone,
1028
1060
  plain,
1029
1061
  brandPlugin,
@@ -1036,13 +1068,16 @@ const createContext = (ir, input, options, plain) => {
1036
1068
  };
1037
1069
  };
1038
1070
  const settle = (ctx) => {
1039
- if (ctx.diagnostics.length > 0) throw new IrRenderError(ctx.diagnostics);
1071
+ if (ctx.diagnostics.length > 0) throw new RenderError({ diagnostics: ctx.diagnostics });
1040
1072
  };
1041
- /** Render the email channel of a compiled template against one validated input. */
1042
- const renderIr = (ir, input, options = {}) => {
1043
- const email = ir.email;
1073
+ /**
1074
+ * Render the email channel against one validated input. `positions` also returns where each
1075
+ * element landed in the HTML, which compatibility findings and editor selections map back to source.
1076
+ */
1077
+ const renderEmail = (template, input, options = {}) => {
1078
+ const ctx = createContext(template, input, options, false);
1079
+ const email = template.email;
1044
1080
  if (email === void 0) return fail("missing-channel", "The template has no email channel.");
1045
- const ctx = createContext(ir, input, options, false);
1046
1081
  const empty = /* @__PURE__ */ new Map();
1047
1082
  const subject = textOf(evalValue(ctx, empty, email.subject, 0));
1048
1083
  if (/[\r\n\u0000]/.test(subject)) fail("invalid-subject", "Invalid email subject: it must be one line of text.");
@@ -1075,7 +1110,7 @@ const renderIr = (ir, input, options = {}) => {
1075
1110
  positions: shifted.positions
1076
1111
  };
1077
1112
  }
1078
- const serialized = tree.map(serialize$1).join("");
1113
+ const serialized = tree.map(serialize).join("");
1079
1114
  return {
1080
1115
  subject,
1081
1116
  preheader,
@@ -1088,14 +1123,16 @@ const plainText = (ctx, nodes) => {
1088
1123
  for (const node of asArray(nodes)) evalNode(ctx, /* @__PURE__ */ new Map(), node, 0, out);
1089
1124
  return out.map((node) => node.t === "text" ? node.v : node.t === "raw" ? node.text : "").join("");
1090
1125
  };
1091
- /** Render the SMS channel of a compiled template to plain text. */
1092
- const renderIrSms = (ir, input, options = {}) => {
1093
- const sms = ir.sms;
1126
+ const renderSms = (template, input, options) => {
1127
+ const ctx = createContext(template, input, options, true);
1128
+ const sms = template.sms;
1094
1129
  if (sms === void 0) return fail("missing-channel", "The template has no SMS channel.");
1095
- const ctx = createContext(ir, input, options, true);
1096
1130
  const text = plainText(ctx, sms.body);
1097
1131
  settle(ctx);
1098
- return text;
1132
+ return {
1133
+ text,
1134
+ segments: smsSegmentInfo(text)
1135
+ };
1099
1136
  };
1100
1137
  const resolveButtons = (ctx, nodes, out, depth) => {
1101
1138
  checkDepth(depth);
@@ -1116,7 +1153,7 @@ const resolveButtons = (ctx, nodes, out, depth) => {
1116
1153
  break;
1117
1154
  case "url": {
1118
1155
  const url = value(node.url);
1119
- if (!isSafeUrl$1(url)) invalid("A button URL must be an absolute https URL.");
1156
+ if (!isSafeUrl(url)) invalid("A button URL must be an absolute https URL.");
1120
1157
  out.push({
1121
1158
  type: "url",
1122
1159
  text: value(node.text),
@@ -1150,16 +1187,16 @@ const resolveButtons = (ctx, nodes, out, depth) => {
1150
1187
  }
1151
1188
  }
1152
1189
  };
1153
- /** Render the WhatsApp channel of a compiled template: every text rendered, conditional buttons resolved. */
1154
- const renderIrWhatsApp = (ir, input, options = {}) => {
1155
- const whatsapp = ir.whatsapp;
1190
+ /** The WhatsApp channel with every text rendered and conditional buttons resolved. */
1191
+ const renderWhatsApp = (template, input, options) => {
1192
+ const ctx = createContext(template, input, options, true);
1193
+ const whatsapp = template.whatsapp;
1156
1194
  if (whatsapp === void 0) return fail("missing-channel", "The template has no WhatsApp channel.");
1157
- const ctx = createContext(ir, input, options, true);
1158
1195
  const header = whatsapp.header === void 0 ? void 0 : {
1159
1196
  type: whatsapp.header.type,
1160
1197
  text: plainText(ctx, whatsapp.header.content)
1161
1198
  };
1162
- if (header !== void 0 && header.type !== "text" && !isSafeUrl$1(header.text)) invalid("A media header URL must be an absolute https URL.");
1199
+ if (header !== void 0 && header.type !== "text" && !isSafeUrl(header.text)) invalid("A media header URL must be an absolute https URL.");
1163
1200
  const body = plainText(ctx, whatsapp.body);
1164
1201
  const footer = whatsapp.footer === void 0 ? void 0 : plainText(ctx, whatsapp.footer);
1165
1202
  const buttons = [];
@@ -1174,470 +1211,16 @@ const renderIrWhatsApp = (ir, input, options = {}) => {
1174
1211
  buttons
1175
1212
  };
1176
1213
  };
1177
- //#endregion
1178
- //#region src/preview.ts
1179
- /**
1180
- * The attribute a preview stamps so a click in a rendered document resolves to exactly one
1181
- * rendered element. A delivered message never carries it.
1182
- */
1183
- const INSTANCE_PATH_ATTRIBUTE = "data-samva-instance";
1184
- const locationKey = (origin) => `${origin.fileName}:${origin.lineNumber}:${origin.columnNumber}`;
1185
- const originKey = (selection) => `${selection.tag}\u0000${selection.origins.map(locationKey).join("\0")}`;
1186
- const parentOf = (instancePath) => {
1187
- const cut = instancePath.lastIndexOf(".");
1188
- return cut === -1 ? void 0 : instancePath.slice(0, cut);
1189
- };
1190
- /**
1191
- * Turn one render's positions into selections, in document order. `occurrence` and `occurrences`
1192
- * count the rendered elements that share an authored origin and tag.
1193
- */
1194
- const emailSelections = (positions) => {
1195
- const counts = /* @__PURE__ */ new Map();
1196
- for (const position of positions) {
1197
- const key = originKey(position);
1198
- counts.set(key, (counts.get(key) ?? 0) + 1);
1199
- }
1200
- const seen = /* @__PURE__ */ new Map();
1201
- return [...positions].toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start).map((position) => {
1202
- const key = originKey(position);
1203
- const occurrence = (seen.get(key) ?? 0) + 1;
1204
- seen.set(key, occurrence);
1205
- const parentPath = parentOf(position.instancePath);
1206
- return {
1207
- instancePath: position.instancePath,
1208
- tag: position.tag,
1209
- origins: position.origins,
1210
- authored: position.authored,
1211
- occurrence,
1212
- occurrences: counts.get(key) ?? 1,
1213
- ...parentPath === void 0 ? {} : { parentPath },
1214
- start: position.start,
1215
- end: position.end
1216
- };
1217
- });
1218
- };
1219
- /**
1220
- * Render one fixture with an instance path stamped on every element, and the selection each path
1221
- * resolves to. The selections come from the spans the compiler recorded, so a click on the canvas
1222
- * lands on the exact node of the source that produced it.
1223
- */
1224
- const renderIrPreview = (ir, input, options = {}) => {
1225
- const rendered = renderIr(ir, input, {
1226
- ...options,
1227
- positions: true
1228
- });
1229
- const sources = ir.sources ?? [];
1230
- const ordered = (rendered.positions ?? []).map((position) => {
1231
- const src = position.src;
1232
- const origins = src === void 0 ? [] : [{
1233
- fileName: sources[src[2] ?? 0] ?? "",
1234
- lineNumber: src[0],
1235
- columnNumber: src[1]
1236
- }];
1237
- return {
1238
- start: position.start,
1239
- end: position.end,
1240
- tag: position.tag,
1241
- src: src === void 0 ? void 0 : src.join(":"),
1242
- origins,
1243
- path: "",
1244
- parent: void 0
1245
- };
1246
- }).toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start);
1247
- const open = [];
1248
- const childCount = /* @__PURE__ */ new Map();
1249
- for (const element of ordered) {
1250
- while (open.length > 0 && open[open.length - 1].end <= element.start) open.pop();
1251
- const parent = open[open.length - 1];
1252
- const index = childCount.get(parent) ?? 0;
1253
- childCount.set(parent, index + 1);
1254
- element.parent = parent;
1255
- element.path = parent === void 0 ? String(index) : `${parent.path}.${index}`;
1256
- open.push(element);
1257
- }
1258
- const inserts = ordered.map((element) => ({
1259
- at: element.start + 1 + element.tag.length,
1260
- text: ` ${INSTANCE_PATH_ATTRIBUTE}="${element.path}"`
1261
- }));
1262
- inserts.sort((left, right) => left.at - right.at);
1263
- let html = "";
1264
- let cursor = 0;
1265
- for (const insert of inserts) {
1266
- html += rendered.html.slice(cursor, insert.at) + insert.text;
1267
- cursor = insert.at;
1268
- }
1269
- html += rendered.html.slice(cursor);
1270
- const shift = (offset) => {
1271
- let moved = 0;
1272
- for (const insert of inserts) {
1273
- if (insert.at > offset) break;
1274
- moved += insert.text.length;
1275
- }
1276
- return offset + moved;
1277
- };
1278
- const positions = ordered.map((element) => ({
1279
- start: shift(element.start),
1280
- end: shift(element.end),
1281
- origins: element.origins,
1282
- instancePath: element.path,
1283
- tag: element.tag,
1284
- authored: element.src !== void 0 && (element.parent === void 0 || element.parent.src !== element.src)
1285
- }));
1286
- const selections = emailSelections(positions);
1287
- return {
1288
- subject: rendered.subject,
1289
- preheader: rendered.preheader,
1290
- html,
1291
- text: rendered.text,
1292
- selections
1293
- };
1294
- };
1295
- /** Look selections up by the instance path a rendered document carries. */
1296
- const emailSelectionIndex = (selections) => new Map(selections.map((selection) => [selection.instancePath, selection]));
1297
- /**
1298
- * The authoring element a selection edits. For an authored element that is the element itself; for
1299
- * a generated wrapper it is the primitive that asked for it. Undefined only where the compiler
1300
- * generated markup with no authoring behind it, which nothing can edit in place.
1301
- */
1302
- const emailAuthoringTarget = (selection) => selection.origins[0];
1303
- /** Root-first ancestry of a selection, itself last. */
1304
- const emailSelectionAncestry = (index, instancePath) => {
1305
- const chain = [];
1306
- let current = index.get(instancePath);
1307
- while (current !== void 0) {
1308
- chain.unshift(current);
1309
- current = current.parentPath === void 0 ? void 0 : index.get(current.parentPath);
1310
- }
1311
- return chain;
1312
- };
1313
- /**
1314
- * Every rendered instance produced by the same authored element, in document order. One entry
1315
- * means it rendered once; several mean it repeated.
1316
- */
1317
- const emailSelectionSiblings = (selections, selection) => {
1318
- const key = originKey(selection);
1319
- return selections.filter((candidate) => originKey(candidate) === key);
1320
- };
1321
- /**
1322
- * Every rendered instance of the same authored element. A partial edited at its definition changes
1323
- * all of these, which is the scope a control has to name before it applies.
1324
- */
1325
- const emailDefinitionInstances = (selections, selection) => {
1326
- const authoring = selection.origins[0];
1327
- const key = `${selection.tag}\u0000${authoring === void 0 ? "" : locationKey(authoring)}`;
1328
- return selections.filter((candidate) => {
1329
- const origin = candidate.origins[0];
1330
- return `${candidate.tag}\u0000${origin === void 0 ? "" : locationKey(origin)}` === key;
1331
- });
1332
- };
1333
- //#endregion
1334
- //#region src/email/html.ts
1335
- /** Escape a value for element content. */
1336
- const escapeText = (value) => value.replace(/[&<>]/g, (character) => ({
1337
- "&": "&amp;",
1338
- "<": "&lt;",
1339
- ">": "&gt;"
1340
- })[character] ?? character);
1341
- /** Escape a value for a double-quoted attribute. */
1342
- const escapeAttribute = (value) => value.replace(/[&<>"']/g, (character) => ({
1343
- "&": "&amp;",
1344
- "<": "&lt;",
1345
- ">": "&gt;",
1346
- "\"": "&quot;",
1347
- "'": "&#39;"
1348
- })[character] ?? character);
1349
- const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
1350
- const SAFE_URL_SCHEME = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
1351
- const RELATIVE_URL = /^(\.{1,2}\/|\/(?!\/))/;
1352
- /**
1353
- * Only references an email client will follow safely; no `javascript:`, no
1354
- * `data:`. A relative reference is legal only for an export: a file on disk can
1355
- * resolve one against its own directory, and a delivered message cannot resolve
1356
- * anything but an absolute URL.
1357
- */
1358
- const isSafeUrl = (value, relative) => !/^[\u0000-\u0020]/.test(value) && !CONTROL_CHARACTERS.test(value) && !value.includes("\\") && (SAFE_URL_SCHEME.test(value) || relative && RELATIVE_URL.test(value));
1359
- const CSS_PROPERTY = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
1360
- const CSS_UNSAFE = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
1361
- const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
1362
- /** camelCase to kebab-case; already-kebab names pass through unchanged. */
1363
- const kebab = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
1364
- const UNITLESS = /* @__PURE__ */ new Set([
1365
- "opacity",
1366
- "zIndex",
1367
- "fontWeight",
1368
- "lineHeight",
1369
- "order",
1370
- "flexGrow",
1371
- "flexShrink",
1372
- "columnCount",
1373
- "orphans",
1374
- "widows",
1375
- "zoom",
1376
- "fillOpacity",
1377
- "strokeOpacity"
1378
- ]);
1379
- /** A bare number is pixels for every property that takes a length. */
1380
- const declarationValue = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
1381
- const report = (sink, code, message, origins, severity = "error") => {
1382
- sink.diagnostics.push({
1383
- code,
1384
- severity,
1385
- message,
1386
- origins
1387
- });
1388
- };
1389
- const declarations = (sink, style, origins) => {
1390
- const parts = [];
1391
- for (const [property, value] of Object.entries(style)) {
1392
- if (value === void 0 || value === null) continue;
1393
- if (!CSS_PROPERTY.test(property)) {
1394
- 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);
1395
- continue;
1396
- }
1397
- if (typeof value !== "string" && typeof value !== "number") {
1398
- report(sink, "invalid-style-value", `Style ${property} must be a string or number, not ${typeof value}.`, origins);
1399
- continue;
1400
- }
1401
- const text = declarationValue(property, value);
1402
- if (CSS_UNSAFE.test(text) || CONTROL_CHARACTERS.test(text)) {
1403
- report(sink, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration; remove ${JSON.stringify(text)}.`, origins);
1404
- continue;
1405
- }
1406
- let unsafeUrl;
1407
- for (const match of text.matchAll(CSS_URL)) if (!isSafeUrl(match[2] ?? "", sink.relativeUrls)) unsafeUrl = match[2] ?? "";
1408
- if (unsafeUrl !== void 0) {
1409
- 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);
1410
- continue;
1411
- }
1412
- parts.push(`${kebab(property)}:${text}`);
1413
- }
1414
- return parts.join(";");
1415
- };
1416
- const attributeValue = (sink, tag, name, spec, value, origins) => {
1417
- if (value === void 0 || value === null || value === false) return void 0;
1418
- if (value === true) return "";
1419
- if (typeof value !== "string" && typeof value !== "number") {
1420
- report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a string or number, not ${typeof value}.`, origins);
1421
- return;
1422
- }
1423
- const text = String(value);
1424
- if (CONTROL_CHARACTERS.test(text)) {
1425
- report(sink, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`, origins);
1426
- return;
1427
- }
1428
- if (spec.values !== void 0 && !spec.values.includes(text)) {
1429
- report(sink, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`, origins);
1430
- return;
1431
- }
1432
- if (spec.type === "number" && !/^-?\d+$/.test(text)) {
1433
- report(sink, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`, origins);
1434
- return;
1435
- }
1436
- if (spec.type === "measure" && !/^\d+(\.\d+)?%?$/.test(text)) {
1437
- report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`, origins);
1438
- return;
1439
- }
1440
- if (spec.type === "url" && !isSafeUrl(text, sink.relativeUrls)) {
1441
- 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);
1442
- return;
1443
- }
1444
- return text;
1445
- };
1446
- const attributes = (sink, tag, spec, props, origins) => {
1447
- const emitted = [];
1448
- const present = /* @__PURE__ */ new Set();
1449
- let classes;
1450
- const unsubscribeLink = tag === "a" && props[UNSUBSCRIBE_LINK] === true;
1451
- const { unsubscribeUrlPlaceholder, noTrackAttribute } = sink.brandPlugin;
1452
- for (const [key, value] of Object.entries(props)) {
1453
- if (key === "key") continue;
1454
- const name = key === "className" ? "class" : key;
1455
- if (unsubscribeLink && name === "href" && value === unsubscribeUrlPlaceholder) {
1456
- present.add(name);
1457
- emitted.push(` href="${escapeAttribute(unsubscribeUrlPlaceholder)}" ${noTrackAttribute}`);
1458
- continue;
1459
- }
1460
- if (name === "class") {
1461
- if (value === void 0 || value === null) continue;
1462
- if (typeof value !== "string") {
1463
- report(sink, "invalid-attribute-value", `<${tag} class> must be a string.`, origins);
1464
- continue;
1465
- }
1466
- if (classes !== void 0) {
1467
- report(sink, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`, origins);
1468
- continue;
1469
- }
1470
- classes = value;
1471
- emitted.push(` class="${escapeAttribute(value)}"`);
1472
- continue;
1473
- }
1474
- if (name === "style") {
1475
- if (value === void 0 || value === null) continue;
1476
- if (!isObject(value) || Array.isArray(value)) {
1477
- report(sink, "invalid-style", `<${tag} style> must be a property object, for example style={{ backgroundColor: "#fff" }}.`, origins);
1478
- continue;
1479
- }
1480
- const css = declarations(sink, value, origins);
1481
- if (css !== "") emitted.push(` style="${escapeAttribute(css)}"`);
1482
- continue;
1483
- }
1484
- if (/^on/i.test(name)) {
1485
- report(sink, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`, origins);
1486
- continue;
1487
- }
1488
- if (sink.reservedAttributes.has(name) && props[BRAND_FOOTER_MARKER] !== true) {
1489
- 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);
1490
- continue;
1491
- }
1492
- if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
1493
- const text = attributeValue(sink, tag, name, { type: "string" }, value, origins);
1494
- if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
1495
- continue;
1496
- }
1497
- const globalAttributes = EMAIL_GLOBAL_ATTRIBUTES;
1498
- const attribute = Object.hasOwn(globalAttributes, name) ? globalAttributes[name] : Object.hasOwn(spec.attributes, name) ? spec.attributes[name] : void 0;
1499
- if (attribute === void 0) {
1500
- const supported = [
1501
- ...Object.keys(EMAIL_GLOBAL_ATTRIBUTES),
1502
- ...Object.keys(spec.attributes),
1503
- "class",
1504
- "style"
1505
- ].sort().join(", ");
1506
- report(sink, "unsupported-attribute", `<${tag}> does not support the ${name} attribute. Supported: ${supported}, plus data-* and aria-*.`, origins);
1507
- continue;
1508
- }
1509
- present.add(name);
1510
- const text = attributeValue(sink, tag, name, attribute, value, origins);
1511
- if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
1512
- }
1513
- 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);
1514
- return emitted.join("");
1515
- };
1516
- /** Fragments are transparent to a content model; raw compatibility markup is not content. */
1517
- const contentChildren = (children) => children.flatMap((child) => child.type === "Fragment" ? contentChildren(child.children) : [child]);
1518
- const checkContentModel = (sink, tag, spec, children, origins) => {
1519
- if (spec.content === "void") {
1520
- if (contentChildren(children).length > 0) report(sink, "void-element-children", `<${tag}> cannot have children.`, origins);
1521
- return;
1522
- }
1523
- if (spec.content === "text" || spec.content === "raw") {
1524
- for (const child of contentChildren(children)) if (child.type !== "Text") report(sink, "invalid-content", `<${tag}> accepts text only.`, child.type === "Element" ? child.origins : origins);
1525
- return;
1526
- }
1527
- if (spec.childElements === void 0) return;
1528
- for (const child of contentChildren(children)) {
1529
- if (child.type === "Raw") continue;
1530
- if (child.type === "Text") {
1531
- if (child.value.trim() !== "") report(sink, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`, origins);
1532
- continue;
1533
- }
1534
- 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);
1535
- }
1536
- };
1537
- /** Every write goes through here so `sink.written` stays the emitted offset. */
1538
- const emit = (sink, text) => {
1539
- sink.written += text.length;
1540
- return text;
1541
- };
1542
- const serialize = (sink, node, parentPath, counter) => {
1543
- switch (node.type) {
1544
- case "Text": return emit(sink, escapeText(node.value));
1545
- case "Raw": return emit(sink, node.html);
1546
- case "Fragment": return node.children.map((child) => serialize(sink, child, parentPath, counter)).join("");
1547
- case "Element": {
1548
- const start = sink.written;
1549
- const index = counter.next++;
1550
- const path = parentPath === "" ? String(index) : `${parentPath}.${index}`;
1551
- const own = { next: 0 };
1552
- const stamp = sink.instancePaths ? ` ${INSTANCE_PATH_ATTRIBUTE}="${path}"` : "";
1553
- const spec = emailElement(node.tag);
1554
- if (spec === void 0) {
1555
- report(sink, "unsupported-element", `<${node.tag}> is not part of the supported email vocabulary. Supported elements: ${Object.keys(EMAIL_ELEMENTS).join(", ")}.`, node.origins);
1556
- return "";
1557
- }
1558
- checkContentModel(sink, node.tag, spec, node.children, node.origins);
1559
- if (spec.content === "raw") {
1560
- const css = node.children.map((child) => child.type === "Text" ? child.value : "").join("");
1561
- if (/<\/|<!--/i.test(css)) {
1562
- report(sink, "unsafe-raw-text", `<${node.tag}> content cannot contain markup.`, node.origins);
1563
- return "";
1564
- }
1565
- const rawAttrs = attributes(sink, node.tag, spec, node.props, node.origins);
1566
- const raw = css === "" ? "" : `<${node.tag}${rawAttrs}${stamp}>${css}</${node.tag}>`;
1567
- return record(sink, start, node, path, emit(sink, raw));
1568
- }
1569
- const attrs = attributes(sink, node.tag, spec, node.props, node.origins);
1570
- if (spec.content === "void") return record(sink, start, node, path, emit(sink, `<${node.tag}${attrs}${stamp} />`));
1571
- const open = emit(sink, `<${node.tag}${attrs}${stamp}>`);
1572
- const children = node.children.map((child) => serialize(sink, child, path, own)).join("");
1573
- const close = emit(sink, `</${node.tag}>`);
1574
- return record(sink, start, node, path, `${open}${children}${close}`);
1575
- }
1576
- }
1577
- };
1578
- /** Record an element's emitted range once its content is written. */
1579
- const record = (sink, start, node, instancePath, text) => {
1580
- sink.positions.push({
1581
- start,
1582
- end: sink.written,
1583
- origins: node.origins,
1584
- instancePath,
1585
- tag: node.tag,
1586
- authored: node.authored
1587
- });
1588
- return text;
1589
- };
1590
- const serializationSink = (options) => {
1591
- const brandPlugin = options.brandPlugin ?? samvaBrandPlugin;
1592
- return {
1593
- diagnostics: [],
1594
- positions: [],
1595
- written: 0,
1596
- relativeUrls: options.relativeUrls === true,
1597
- instancePaths: options.instancePaths === true,
1598
- brandPlugin,
1599
- reservedAttributes: reservedBrandAttributes(brandPlugin)
1600
- };
1601
- };
1602
- /** Lower the derived tree to email HTML, reporting every unsupported construct with its origin. */
1603
- const serializeEmailHtml = (node, options = {}) => {
1604
- const sink = serializationSink(options);
1605
- return {
1606
- html: serialize(sink, node, "", { next: 0 }),
1607
- diagnostics: sink.diagnostics,
1608
- positions: sink.positions
1609
- };
1610
- };
1611
- /** An authored style object as a declaration list, checked by the same rules as a serialized tree. */
1612
- const serializeStyle = (style, origins) => {
1613
- const sink = serializationSink({});
1614
- return {
1615
- css: declarations(sink, style, origins),
1616
- diagnostics: sink.diagnostics
1617
- };
1214
+ const CHANNEL_RENDERERS = {
1215
+ email: (template, input, options) => renderEmail(template, input, options),
1216
+ sms: renderSms,
1217
+ whatsapp: renderWhatsApp
1618
1218
  };
1619
1219
  /**
1620
- * Check one static attribute value against its element's spec, as serialization does. Returns
1621
- * the text that would be emitted, or `undefined` when the attribute is dropped or invalid.
1220
+ * Render one channel of a compiled template against one validated input. It throws only
1221
+ * `RenderError`: a template compiled by another version, a channel the template does not declare,
1222
+ * or input or markup the renderer refuses.
1622
1223
  */
1623
- const checkAttribute = (tag, name, value, origins) => {
1624
- const sink = serializationSink({});
1625
- const element = emailElement(tag);
1626
- const globals = EMAIL_GLOBAL_ATTRIBUTES;
1627
- const attributeSpec = () => {
1628
- if (/^(data|aria)-[a-z0-9-]+$/.test(name)) return { type: "string" };
1629
- if (Object.hasOwn(globals, name)) return globals[name];
1630
- if (element !== void 0 && Object.hasOwn(element.attributes, name)) return element.attributes[name];
1631
- };
1632
- const spec = attributeSpec();
1633
- if (spec === void 0) return {
1634
- text: void 0,
1635
- diagnostics: sink.diagnostics
1636
- };
1637
- return {
1638
- text: attributeValue(sink, tag, name, spec, value, origins),
1639
- diagnostics: sink.diagnostics
1640
- };
1641
- };
1224
+ const render = (template, input, options) => CHANNEL_RENDERERS[options.channel](template, input, options);
1642
1225
  //#endregion
1643
- export { isUrlAttribute as C, samvaBrandPlugin as D, reservedBrandAttributes as E, BRAND_FOOTER_MARKER as O, emailElement as S, parseBrandSpecifier as T, renderIr as _, kebab as a, EMAIL_ELEMENTS as b, INSTANCE_PATH_ATTRIBUTE as c, emailSelectionAncestry as d, emailSelectionIndex as f, IrRenderError as g, renderIrPreview as h, escapeText as i, UNSUBSCRIBE_LINK as k, emailAuthoringTarget as l, emailSelections as m, declarationValue as n, serializeEmailHtml as o, emailSelectionSiblings as p, escapeAttribute as r, serializeStyle as s, checkAttribute as t, emailDefinitionInstances as u, renderIrSms as v, brandSpecifierPattern as w, EMAIL_GLOBAL_ATTRIBUTES as x, renderIrWhatsApp as y };
1226
+ export { EMAIL_ELEMENTS as a, isUrlAttribute as c, reservedBrandAttributes as d, samvaBrandPlugin as f, smsSegmentInfo as i, brandSpecifierPattern as l, render as n, EMAIL_GLOBAL_ATTRIBUTES as o, COMPILED_TEMPLATE_VERSION as p, renderEmail as r, emailElement as s, RenderError as t, parseBrandSpecifier as u };