@samva/markup 0.15.1 → 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.
- package/AGENTS.md +42 -35
- package/CHANGELOG.md +92 -0
- package/README.md +10 -9
- package/dist/{assets-tISHaQNF.d.ts → assets-CS5Xa_mr.d.ts} +3 -3
- package/dist/{brand-nreeKWxF.js → brand-CIiEqkU7.js} +24 -31
- package/dist/{brand-plugin-CHhcei-j.d.ts → brand-plugin-Dj9SMpfS.d.ts} +3 -1
- package/dist/brand.d.ts +2 -2
- package/dist/brand.js +3 -3
- package/dist/compatibility.d.ts +76 -0
- package/dist/compatibility.js +807 -0
- package/dist/compiled-template-Bc4Dq9zy.d.ts +268 -0
- package/dist/compiler.d.ts +58 -225
- package/dist/compiler.js +261 -1297
- package/dist/{components-3XHjYD3i.js → components-C5850-Qg.js} +37 -29
- package/dist/css-B_aVKrDo.js +436 -0
- package/dist/css-color-DLM12Osu.js +315 -0
- package/dist/{diagnostic-codes-UXwOO3A-.js → diagnostic-codes-DZKb0Xcg.js} +4 -0
- package/dist/{diagnostics-CUYlQ_6x.d.ts → diagnostics-D4vSLQrO.d.ts} +1 -10
- package/dist/diagnostics.d.ts +6 -2
- package/dist/diagnostics.js +3 -3
- package/dist/edit.d.ts +10 -9
- package/dist/edit.js +12 -11
- package/dist/email/jsx-dev-runtime.d.ts +1 -1
- package/dist/email/jsx-dev-runtime.js +1 -1
- package/dist/email/jsx-runtime.d.ts +2 -2
- package/dist/email/jsx-runtime.js +2 -2
- package/dist/email.d.ts +1 -1
- package/dist/email.js +1 -1
- package/dist/html-DJUK2YJF.js +483 -0
- package/dist/index.d.ts +1 -1
- package/dist/{jsx-runtime-DC7xG0aA.d.ts → jsx-runtime-DL8NVF1f.d.ts} +12 -6
- package/dist/{jsx-runtime-CBlexoeR.js → jsx-runtime-qL19u3Fy.js} +17 -10
- package/dist/preview-rNoJBEIY.d.ts +104 -0
- package/dist/{profile-AatO_SvP.js → profile-JTvgCD6X.js} +614 -24
- package/dist/{html-BvRFepeO.js → render-template-DwPDm3Hq.js} +155 -572
- package/dist/render.d.ts +40 -28
- package/dist/render.js +3 -47
- package/dist/selectors-CYWCoxZr.js +157 -0
- package/dist/sms.d.ts +1 -1
- package/dist/sms.js +1 -1
- package/dist/{template-B-UDXK8a.d.ts → template-DmNcoH4Q.d.ts} +2 -9
- package/dist/{theme-css-s9QwD5Ko.js → theme-css-P9F5S7Ic.js} +3 -316
- package/dist/whatsapp.d.ts +1 -1
- package/dist/whatsapp.js +1 -1
- package/generated/contract.json +7 -2
- package/generated/reference.md +3 -2
- package/package.json +5 -1
- package/skills/sml/SKILL.md +2 -2
- package/dist/ir-DNL6H7jA.js +0 -705
- package/dist/render-BdvewoQt.d.ts +0 -564
- package/dist/render-DpYouYYG.js +0 -258
|
@@ -1,19 +1,8 @@
|
|
|
1
|
-
import { n as isObject } from "./guards-eV1qLbf2.js";
|
|
2
1
|
import { callFormatter, createFmt } from "./fmt.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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,
|
|
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/
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
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
|
|
398
|
+
const escapeText = (value) => value.replace(/[&<>]/g, (character) => ({
|
|
368
399
|
"&": "&",
|
|
369
400
|
"<": "<",
|
|
370
401
|
">": ">"
|
|
371
402
|
})[character] ?? character);
|
|
372
|
-
const escapeAttribute
|
|
403
|
+
const escapeAttribute = (value) => value.replace(/[&<>"']/g, (character) => ({
|
|
373
404
|
"&": "&",
|
|
374
405
|
"<": "<",
|
|
375
406
|
">": ">",
|
|
376
407
|
"\"": """,
|
|
377
408
|
"'": "'"
|
|
378
409
|
})[character] ?? character);
|
|
379
|
-
const CONTROL_CHARACTERS
|
|
380
|
-
const SAFE_URL_SCHEME
|
|
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
|
|
383
|
-
const CSS_PROPERTY
|
|
384
|
-
const CSS_UNSAFE
|
|
385
|
-
const CSS_URL
|
|
386
|
-
const kebab
|
|
387
|
-
const UNITLESS
|
|
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
|
|
403
|
-
const report
|
|
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
|
|
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
|
|
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
|
|
555
|
-
report
|
|
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
|
|
590
|
+
report(ctx, "invalid-style-value", `Style ${property} must be a string or finite number.`);
|
|
560
591
|
continue;
|
|
561
592
|
}
|
|
562
|
-
const text = declarationValue
|
|
563
|
-
if (CSS_UNSAFE
|
|
564
|
-
report
|
|
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
|
|
599
|
+
for (const match of text.matchAll(CSS_URL)) if (!isSafeUrl(match[2] ?? "")) unsafeUrl = match[2] ?? "";
|
|
569
600
|
if (unsafeUrl !== void 0) {
|
|
570
|
-
report
|
|
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
|
|
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
|
|
581
|
-
report
|
|
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
|
|
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
|
|
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
|
|
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
|
|
600
|
-
report
|
|
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
|
|
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
|
|
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
|
|
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
|
|
616
|
-
report
|
|
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
|
|
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
|
|
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
|
|
674
|
+
report(ctx, "invalid-attribute-value", `<${tag} class> must be a string.`);
|
|
644
675
|
continue;
|
|
645
676
|
}
|
|
646
|
-
if (CONTROL_CHARACTERS
|
|
647
|
-
report
|
|
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
|
|
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
|
|
686
|
+
emitted.push(` class="${escapeAttribute(value)}"`);
|
|
656
687
|
continue;
|
|
657
688
|
}
|
|
658
689
|
if (name === "style") {
|
|
659
|
-
report
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
719
|
+
if (css !== "") emitted.push(` style="${escapeAttribute(css)}"`);
|
|
689
720
|
}
|
|
690
721
|
return emitted.join("");
|
|
691
722
|
};
|
|
692
|
-
const checkContentModel
|
|
723
|
+
const checkContentModel = (ctx, tag, spec, children) => {
|
|
693
724
|
if (spec.content === "void") {
|
|
694
|
-
if (children.length > 0) report
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
772
|
-
if (isRecord(record) && has(record, "text")) return escapeText
|
|
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
|
|
909
|
+
const serialize = (node) => {
|
|
879
910
|
switch (node.t) {
|
|
880
|
-
case "text": return escapeText
|
|
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
|
|
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
|
|
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 = (
|
|
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 ??
|
|
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
|
|
1071
|
+
if (ctx.diagnostics.length > 0) throw new RenderError({ diagnostics: ctx.diagnostics });
|
|
1040
1072
|
};
|
|
1041
|
-
/**
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
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
|
-
|
|
1092
|
-
const
|
|
1093
|
-
const 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
|
|
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
|
|
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
|
-
/**
|
|
1154
|
-
const
|
|
1155
|
-
const
|
|
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
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
"&": "&",
|
|
1338
|
-
"<": "<",
|
|
1339
|
-
">": ">"
|
|
1340
|
-
})[character] ?? character);
|
|
1341
|
-
/** Escape a value for a double-quoted attribute. */
|
|
1342
|
-
const escapeAttribute = (value) => value.replace(/[&<>"']/g, (character) => ({
|
|
1343
|
-
"&": "&",
|
|
1344
|
-
"<": "<",
|
|
1345
|
-
">": ">",
|
|
1346
|
-
"\"": """,
|
|
1347
|
-
"'": "'"
|
|
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
|
-
*
|
|
1621
|
-
*
|
|
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
|
|
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 {
|
|
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 };
|