@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
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { n as isObject } from "./guards-eV1qLbf2.js";
|
|
2
|
+
import { a as EMAIL_ELEMENTS, d as reservedBrandAttributes, f as samvaBrandPlugin, o as EMAIL_GLOBAL_ATTRIBUTES, r as renderEmail, s as emailElement } from "./render-template-DwPDm3Hq.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");
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/preview.ts
|
|
19
|
+
/**
|
|
20
|
+
* The attribute a preview stamps so a click in a rendered document resolves to exactly one
|
|
21
|
+
* rendered element. A delivered message never carries it.
|
|
22
|
+
*/
|
|
23
|
+
const INSTANCE_PATH_ATTRIBUTE = "data-samva-instance";
|
|
24
|
+
const locationKey = (origin) => `${origin.fileName}:${origin.lineNumber}:${origin.columnNumber}`;
|
|
25
|
+
const originKey = (selection) => `${selection.tag}\u0000${selection.origins.map(locationKey).join("\0")}`;
|
|
26
|
+
const parentOf = (instancePath) => {
|
|
27
|
+
const cut = instancePath.lastIndexOf(".");
|
|
28
|
+
return cut === -1 ? void 0 : instancePath.slice(0, cut);
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Turn one render's positions into selections, in document order. `occurrence` and `occurrences`
|
|
32
|
+
* count the rendered elements that share an authored origin and tag.
|
|
33
|
+
*/
|
|
34
|
+
const emailSelections = (positions) => {
|
|
35
|
+
const counts = /* @__PURE__ */ new Map();
|
|
36
|
+
for (const position of positions) {
|
|
37
|
+
const key = originKey(position);
|
|
38
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
39
|
+
}
|
|
40
|
+
const seen = /* @__PURE__ */ new Map();
|
|
41
|
+
return [...positions].toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start).map((position) => {
|
|
42
|
+
const key = originKey(position);
|
|
43
|
+
const occurrence = (seen.get(key) ?? 0) + 1;
|
|
44
|
+
seen.set(key, occurrence);
|
|
45
|
+
const parentPath = parentOf(position.instancePath);
|
|
46
|
+
return {
|
|
47
|
+
instancePath: position.instancePath,
|
|
48
|
+
tag: position.tag,
|
|
49
|
+
origins: position.origins,
|
|
50
|
+
authored: position.authored,
|
|
51
|
+
occurrence,
|
|
52
|
+
occurrences: counts.get(key) ?? 1,
|
|
53
|
+
...parentPath === void 0 ? {} : { parentPath },
|
|
54
|
+
start: position.start,
|
|
55
|
+
end: position.end
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Render one fixture with an instance path stamped on every element, and the selection each path
|
|
61
|
+
* resolves to. The selections come from the spans the compiler recorded, so a click on the canvas
|
|
62
|
+
* lands on the exact node of the source that produced it.
|
|
63
|
+
*/
|
|
64
|
+
const previewEmail = (template, input, options = {}) => {
|
|
65
|
+
const rendered = renderEmail(template, input, {
|
|
66
|
+
...options,
|
|
67
|
+
positions: true
|
|
68
|
+
});
|
|
69
|
+
const sources = template.sources ?? [];
|
|
70
|
+
const ordered = (rendered.positions ?? []).map((position) => {
|
|
71
|
+
const src = position.src;
|
|
72
|
+
const origins = src === void 0 ? [] : [{
|
|
73
|
+
fileName: sources[src[2] ?? 0] ?? "",
|
|
74
|
+
lineNumber: src[0],
|
|
75
|
+
columnNumber: src[1]
|
|
76
|
+
}];
|
|
77
|
+
return {
|
|
78
|
+
start: position.start,
|
|
79
|
+
end: position.end,
|
|
80
|
+
tag: position.tag,
|
|
81
|
+
src: src === void 0 ? void 0 : src.join(":"),
|
|
82
|
+
origins,
|
|
83
|
+
path: "",
|
|
84
|
+
parent: void 0
|
|
85
|
+
};
|
|
86
|
+
}).toSorted((left, right) => left.start === right.start ? right.end - left.end : left.start - right.start);
|
|
87
|
+
const open = [];
|
|
88
|
+
const childCount = /* @__PURE__ */ new Map();
|
|
89
|
+
for (const element of ordered) {
|
|
90
|
+
while (open.length > 0 && open[open.length - 1].end <= element.start) open.pop();
|
|
91
|
+
const parent = open[open.length - 1];
|
|
92
|
+
const index = childCount.get(parent) ?? 0;
|
|
93
|
+
childCount.set(parent, index + 1);
|
|
94
|
+
element.parent = parent;
|
|
95
|
+
element.path = parent === void 0 ? String(index) : `${parent.path}.${index}`;
|
|
96
|
+
open.push(element);
|
|
97
|
+
}
|
|
98
|
+
const inserts = ordered.map((element) => ({
|
|
99
|
+
at: element.start + 1 + element.tag.length,
|
|
100
|
+
text: ` ${INSTANCE_PATH_ATTRIBUTE}="${element.path}"`
|
|
101
|
+
}));
|
|
102
|
+
inserts.sort((left, right) => left.at - right.at);
|
|
103
|
+
let html = "";
|
|
104
|
+
let cursor = 0;
|
|
105
|
+
for (const insert of inserts) {
|
|
106
|
+
html += rendered.html.slice(cursor, insert.at) + insert.text;
|
|
107
|
+
cursor = insert.at;
|
|
108
|
+
}
|
|
109
|
+
html += rendered.html.slice(cursor);
|
|
110
|
+
const shift = (offset) => {
|
|
111
|
+
let moved = 0;
|
|
112
|
+
for (const insert of inserts) {
|
|
113
|
+
if (insert.at > offset) break;
|
|
114
|
+
moved += insert.text.length;
|
|
115
|
+
}
|
|
116
|
+
return offset + moved;
|
|
117
|
+
};
|
|
118
|
+
const positions = ordered.map((element) => ({
|
|
119
|
+
start: shift(element.start),
|
|
120
|
+
end: shift(element.end),
|
|
121
|
+
origins: element.origins,
|
|
122
|
+
instancePath: element.path,
|
|
123
|
+
tag: element.tag,
|
|
124
|
+
authored: element.src !== void 0 && (element.parent === void 0 || element.parent.src !== element.src)
|
|
125
|
+
}));
|
|
126
|
+
const selections = emailSelections(positions);
|
|
127
|
+
return {
|
|
128
|
+
subject: rendered.subject,
|
|
129
|
+
preheader: rendered.preheader,
|
|
130
|
+
html,
|
|
131
|
+
text: rendered.text,
|
|
132
|
+
selections
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
/** Look selections up by the instance path a rendered document carries. */
|
|
136
|
+
const emailSelectionIndex = (selections) => new Map(selections.map((selection) => [selection.instancePath, selection]));
|
|
137
|
+
/**
|
|
138
|
+
* The authoring element a selection edits. For an authored element that is the element itself; for
|
|
139
|
+
* a generated wrapper it is the primitive that asked for it. Undefined only where the compiler
|
|
140
|
+
* generated markup with no authoring behind it, which nothing can edit in place.
|
|
141
|
+
*/
|
|
142
|
+
const emailAuthoringTarget = (selection) => selection.origins[0];
|
|
143
|
+
/** Root-first ancestry of a selection, itself last. */
|
|
144
|
+
const emailSelectionAncestry = (index, instancePath) => {
|
|
145
|
+
const chain = [];
|
|
146
|
+
let current = index.get(instancePath);
|
|
147
|
+
while (current !== void 0) {
|
|
148
|
+
chain.unshift(current);
|
|
149
|
+
current = current.parentPath === void 0 ? void 0 : index.get(current.parentPath);
|
|
150
|
+
}
|
|
151
|
+
return chain;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Every rendered instance produced by the same authored element, in document order. One entry
|
|
155
|
+
* means it rendered once; several mean it repeated.
|
|
156
|
+
*/
|
|
157
|
+
const emailSelectionSiblings = (selections, selection) => {
|
|
158
|
+
const key = originKey(selection);
|
|
159
|
+
return selections.filter((candidate) => originKey(candidate) === key);
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Every rendered instance of the same authored element. A partial edited at its definition changes
|
|
163
|
+
* all of these, which is the scope a control has to name before it applies.
|
|
164
|
+
*/
|
|
165
|
+
const emailDefinitionInstances = (selections, selection) => {
|
|
166
|
+
const authoring = selection.origins[0];
|
|
167
|
+
const key = `${selection.tag}\u0000${authoring === void 0 ? "" : locationKey(authoring)}`;
|
|
168
|
+
return selections.filter((candidate) => {
|
|
169
|
+
const origin = candidate.origins[0];
|
|
170
|
+
return `${candidate.tag}\u0000${origin === void 0 ? "" : locationKey(origin)}` === key;
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/email/html.ts
|
|
175
|
+
/** Escape a value for element content. */
|
|
176
|
+
const escapeText = (value) => value.replace(/[&<>]/g, (character) => ({
|
|
177
|
+
"&": "&",
|
|
178
|
+
"<": "<",
|
|
179
|
+
">": ">"
|
|
180
|
+
})[character] ?? character);
|
|
181
|
+
/** Escape a value for a double-quoted attribute. */
|
|
182
|
+
const escapeAttribute = (value) => value.replace(/[&<>"']/g, (character) => ({
|
|
183
|
+
"&": "&",
|
|
184
|
+
"<": "<",
|
|
185
|
+
">": ">",
|
|
186
|
+
"\"": """,
|
|
187
|
+
"'": "'"
|
|
188
|
+
})[character] ?? character);
|
|
189
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
190
|
+
const SAFE_URL_SCHEME = /^(https:\/\/|mailto:|tel:|cid:|#)/i;
|
|
191
|
+
const RELATIVE_URL = /^(\.{1,2}\/|\/(?!\/))/;
|
|
192
|
+
/**
|
|
193
|
+
* Only references an email client will follow safely; no `javascript:`, no
|
|
194
|
+
* `data:`. A relative reference is legal only for an export: a file on disk can
|
|
195
|
+
* resolve one against its own directory, and a delivered message cannot resolve
|
|
196
|
+
* anything but an absolute URL.
|
|
197
|
+
*/
|
|
198
|
+
const isSafeUrl = (value, relative) => !/^[\u0000-\u0020]/.test(value) && !CONTROL_CHARACTERS.test(value) && !value.includes("\\") && (SAFE_URL_SCHEME.test(value) || relative && RELATIVE_URL.test(value));
|
|
199
|
+
const CSS_PROPERTY = /^(--[a-zA-Z0-9-]+|[a-zA-Z][a-zA-Z0-9-]*)$/;
|
|
200
|
+
const CSS_UNSAFE = /[;{}<>\\]|expression\s*\(|@import|\/\*/i;
|
|
201
|
+
const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
|
|
202
|
+
/** camelCase to kebab-case; already-kebab names pass through unchanged. */
|
|
203
|
+
const kebab = (property) => property.startsWith("--") ? property : property.replace(/[A-Z]/g, (l) => `-${l.toLowerCase()}`);
|
|
204
|
+
const UNITLESS = /* @__PURE__ */ new Set([
|
|
205
|
+
"opacity",
|
|
206
|
+
"zIndex",
|
|
207
|
+
"fontWeight",
|
|
208
|
+
"lineHeight",
|
|
209
|
+
"order",
|
|
210
|
+
"flexGrow",
|
|
211
|
+
"flexShrink",
|
|
212
|
+
"columnCount",
|
|
213
|
+
"orphans",
|
|
214
|
+
"widows",
|
|
215
|
+
"zoom",
|
|
216
|
+
"fillOpacity",
|
|
217
|
+
"strokeOpacity"
|
|
218
|
+
]);
|
|
219
|
+
/** A bare number is pixels for every property that takes a length. */
|
|
220
|
+
const declarationValue = (property, value) => typeof value === "number" && value !== 0 && !UNITLESS.has(property) && !property.startsWith("--") ? `${value}px` : String(value);
|
|
221
|
+
const report = (sink, code, message, origins, severity = "error") => {
|
|
222
|
+
sink.diagnostics.push({
|
|
223
|
+
code,
|
|
224
|
+
severity,
|
|
225
|
+
message,
|
|
226
|
+
origins
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
const declarations = (sink, style, origins) => {
|
|
230
|
+
const parts = [];
|
|
231
|
+
for (const [property, value] of Object.entries(style)) {
|
|
232
|
+
if (value === void 0 || value === null) continue;
|
|
233
|
+
if (!CSS_PROPERTY.test(property)) {
|
|
234
|
+
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);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
238
|
+
report(sink, "invalid-style-value", `Style ${property} must be a string or number, not ${typeof value}.`, origins);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const text = declarationValue(property, value);
|
|
242
|
+
if (CSS_UNSAFE.test(text) || CONTROL_CHARACTERS.test(text)) {
|
|
243
|
+
report(sink, "unsafe-style-value", `Style ${property} contains CSS that could break out of the declaration; remove ${JSON.stringify(text)}.`, origins);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
let unsafeUrl;
|
|
247
|
+
for (const match of text.matchAll(CSS_URL)) if (!isSafeUrl(match[2] ?? "", sink.relativeUrls)) unsafeUrl = match[2] ?? "";
|
|
248
|
+
if (unsafeUrl !== void 0) {
|
|
249
|
+
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);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
parts.push(`${kebab(property)}:${text}`);
|
|
253
|
+
}
|
|
254
|
+
return parts.join(";");
|
|
255
|
+
};
|
|
256
|
+
const attributeValue = (sink, tag, name, spec, value, origins) => {
|
|
257
|
+
if (value === void 0 || value === null || value === false) return void 0;
|
|
258
|
+
if (value === true) return "";
|
|
259
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
260
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a string or number, not ${typeof value}.`, origins);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const text = String(value);
|
|
264
|
+
if (CONTROL_CHARACTERS.test(text)) {
|
|
265
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> contains control characters.`, origins);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (spec.values !== void 0 && !spec.values.includes(text)) {
|
|
269
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> accepts ${spec.values.join(", ")}; received ${JSON.stringify(text)}.`, origins);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (spec.type === "number" && !/^-?\d+$/.test(text)) {
|
|
273
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> must be an integer.`, origins);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (spec.type === "measure" && !/^\d+(\.\d+)?%?$/.test(text)) {
|
|
277
|
+
report(sink, "invalid-attribute-value", `<${tag} ${name}> must be a pixel count or a percentage.`, origins);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (spec.type === "url" && !isSafeUrl(text, sink.relativeUrls)) {
|
|
281
|
+
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);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
return text;
|
|
285
|
+
};
|
|
286
|
+
const attributes = (sink, tag, spec, props, origins) => {
|
|
287
|
+
const emitted = [];
|
|
288
|
+
const present = /* @__PURE__ */ new Set();
|
|
289
|
+
let classes;
|
|
290
|
+
const unsubscribeLink = tag === "a" && props[UNSUBSCRIBE_LINK] === true;
|
|
291
|
+
const { unsubscribeUrlPlaceholder, noTrackAttribute } = sink.brandPlugin;
|
|
292
|
+
for (const [key, value] of Object.entries(props)) {
|
|
293
|
+
if (key === "key") continue;
|
|
294
|
+
const name = key === "className" ? "class" : key;
|
|
295
|
+
if (unsubscribeLink && name === "href" && value === unsubscribeUrlPlaceholder) {
|
|
296
|
+
present.add(name);
|
|
297
|
+
emitted.push(` href="${escapeAttribute(unsubscribeUrlPlaceholder)}" ${noTrackAttribute}`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (name === "class") {
|
|
301
|
+
if (value === void 0 || value === null) continue;
|
|
302
|
+
if (typeof value !== "string") {
|
|
303
|
+
report(sink, "invalid-attribute-value", `<${tag} class> must be a string.`, origins);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (classes !== void 0) {
|
|
307
|
+
report(sink, "duplicate-attribute", `<${tag}> sets both class and className; keep one.`, origins);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
classes = value;
|
|
311
|
+
emitted.push(` class="${escapeAttribute(value)}"`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (name === "style") {
|
|
315
|
+
if (value === void 0 || value === null) continue;
|
|
316
|
+
if (!isObject(value) || Array.isArray(value)) {
|
|
317
|
+
report(sink, "invalid-style", `<${tag} style> must be a property object, for example style={{ backgroundColor: "#fff" }}.`, origins);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const css = declarations(sink, value, origins);
|
|
321
|
+
if (css !== "") emitted.push(` style="${escapeAttribute(css)}"`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (/^on/i.test(name)) {
|
|
325
|
+
report(sink, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`, origins);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (sink.reservedAttributes.has(name) && props[BRAND_FOOTER_MARKER] !== true) {
|
|
329
|
+
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);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
|
|
333
|
+
const text = attributeValue(sink, tag, name, { type: "string" }, value, origins);
|
|
334
|
+
if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const globalAttributes = EMAIL_GLOBAL_ATTRIBUTES;
|
|
338
|
+
const attribute = Object.hasOwn(globalAttributes, name) ? globalAttributes[name] : Object.hasOwn(spec.attributes, name) ? spec.attributes[name] : void 0;
|
|
339
|
+
if (attribute === void 0) {
|
|
340
|
+
const supported = [
|
|
341
|
+
...Object.keys(EMAIL_GLOBAL_ATTRIBUTES),
|
|
342
|
+
...Object.keys(spec.attributes),
|
|
343
|
+
"class",
|
|
344
|
+
"style"
|
|
345
|
+
].sort().join(", ");
|
|
346
|
+
report(sink, "unsupported-attribute", `<${tag}> does not support the ${name} attribute. Supported: ${supported}, plus data-* and aria-*.`, origins);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
present.add(name);
|
|
350
|
+
const text = attributeValue(sink, tag, name, attribute, value, origins);
|
|
351
|
+
if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
|
|
352
|
+
}
|
|
353
|
+
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);
|
|
354
|
+
return emitted.join("");
|
|
355
|
+
};
|
|
356
|
+
/** Fragments are transparent to a content model; raw compatibility markup is not content. */
|
|
357
|
+
const contentChildren = (children) => children.flatMap((child) => child.type === "Fragment" ? contentChildren(child.children) : [child]);
|
|
358
|
+
const checkContentModel = (sink, tag, spec, children, origins) => {
|
|
359
|
+
if (spec.content === "void") {
|
|
360
|
+
if (contentChildren(children).length > 0) report(sink, "void-element-children", `<${tag}> cannot have children.`, origins);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (spec.content === "text" || spec.content === "raw") {
|
|
364
|
+
for (const child of contentChildren(children)) if (child.type !== "Text") report(sink, "invalid-content", `<${tag}> accepts text only.`, child.type === "Element" ? child.origins : origins);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (spec.childElements === void 0) return;
|
|
368
|
+
for (const child of contentChildren(children)) {
|
|
369
|
+
if (child.type === "Raw") continue;
|
|
370
|
+
if (child.type === "Text") {
|
|
371
|
+
if (child.value.trim() !== "") report(sink, "invalid-content", `<${tag}> cannot contain text directly; put it inside <${spec.childElements[0]}>.`, origins);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
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);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
/** Every write goes through here so `sink.written` stays the emitted offset. */
|
|
378
|
+
const emit = (sink, text) => {
|
|
379
|
+
sink.written += text.length;
|
|
380
|
+
return text;
|
|
381
|
+
};
|
|
382
|
+
const serialize = (sink, node, parentPath, counter) => {
|
|
383
|
+
switch (node.type) {
|
|
384
|
+
case "Text": return emit(sink, escapeText(node.value));
|
|
385
|
+
case "Raw": return emit(sink, node.html);
|
|
386
|
+
case "Fragment": return node.children.map((child) => serialize(sink, child, parentPath, counter)).join("");
|
|
387
|
+
case "Element": {
|
|
388
|
+
const start = sink.written;
|
|
389
|
+
const index = counter.next++;
|
|
390
|
+
const path = parentPath === "" ? String(index) : `${parentPath}.${index}`;
|
|
391
|
+
const own = { next: 0 };
|
|
392
|
+
const stamp = sink.instancePaths ? ` ${INSTANCE_PATH_ATTRIBUTE}="${path}"` : "";
|
|
393
|
+
const spec = emailElement(node.tag);
|
|
394
|
+
if (spec === void 0) {
|
|
395
|
+
report(sink, "unsupported-element", `<${node.tag}> is not part of the supported email vocabulary. Supported elements: ${Object.keys(EMAIL_ELEMENTS).join(", ")}.`, node.origins);
|
|
396
|
+
return "";
|
|
397
|
+
}
|
|
398
|
+
checkContentModel(sink, node.tag, spec, node.children, node.origins);
|
|
399
|
+
if (spec.content === "raw") {
|
|
400
|
+
const css = node.children.map((child) => child.type === "Text" ? child.value : "").join("");
|
|
401
|
+
if (/<\/|<!--/i.test(css)) {
|
|
402
|
+
report(sink, "unsafe-raw-text", `<${node.tag}> content cannot contain markup.`, node.origins);
|
|
403
|
+
return "";
|
|
404
|
+
}
|
|
405
|
+
const rawAttrs = attributes(sink, node.tag, spec, node.props, node.origins);
|
|
406
|
+
const raw = css === "" ? "" : `<${node.tag}${rawAttrs}${stamp}>${css}</${node.tag}>`;
|
|
407
|
+
return record(sink, start, node, path, emit(sink, raw));
|
|
408
|
+
}
|
|
409
|
+
const attrs = attributes(sink, node.tag, spec, node.props, node.origins);
|
|
410
|
+
if (spec.content === "void") return record(sink, start, node, path, emit(sink, `<${node.tag}${attrs}${stamp} />`));
|
|
411
|
+
const open = emit(sink, `<${node.tag}${attrs}${stamp}>`);
|
|
412
|
+
const children = node.children.map((child) => serialize(sink, child, path, own)).join("");
|
|
413
|
+
const close = emit(sink, `</${node.tag}>`);
|
|
414
|
+
return record(sink, start, node, path, `${open}${children}${close}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
/** Record an element's emitted range once its content is written. */
|
|
419
|
+
const record = (sink, start, node, instancePath, text) => {
|
|
420
|
+
sink.positions.push({
|
|
421
|
+
start,
|
|
422
|
+
end: sink.written,
|
|
423
|
+
origins: node.origins,
|
|
424
|
+
instancePath,
|
|
425
|
+
tag: node.tag,
|
|
426
|
+
authored: node.authored
|
|
427
|
+
});
|
|
428
|
+
return text;
|
|
429
|
+
};
|
|
430
|
+
const serializationSink = (options) => {
|
|
431
|
+
const brandPlugin = options.brandPlugin ?? samvaBrandPlugin;
|
|
432
|
+
return {
|
|
433
|
+
diagnostics: [],
|
|
434
|
+
positions: [],
|
|
435
|
+
written: 0,
|
|
436
|
+
relativeUrls: options.relativeUrls === true,
|
|
437
|
+
instancePaths: options.instancePaths === true,
|
|
438
|
+
brandPlugin,
|
|
439
|
+
reservedAttributes: reservedBrandAttributes(brandPlugin)
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
/** Lower the derived tree to email HTML, reporting every unsupported construct with its origin. */
|
|
443
|
+
const serializeEmailHtml = (node, options = {}) => {
|
|
444
|
+
const sink = serializationSink(options);
|
|
445
|
+
return {
|
|
446
|
+
html: serialize(sink, node, "", { next: 0 }),
|
|
447
|
+
diagnostics: sink.diagnostics,
|
|
448
|
+
positions: sink.positions
|
|
449
|
+
};
|
|
450
|
+
};
|
|
451
|
+
/** An authored style object as a declaration list, checked by the same rules as a serialized tree. */
|
|
452
|
+
const serializeStyle = (style, origins) => {
|
|
453
|
+
const sink = serializationSink({});
|
|
454
|
+
return {
|
|
455
|
+
css: declarations(sink, style, origins),
|
|
456
|
+
diagnostics: sink.diagnostics
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* Check one static attribute value against its element's spec, as serialization does. Returns
|
|
461
|
+
* the text that would be emitted, or `undefined` when the attribute is dropped or invalid.
|
|
462
|
+
*/
|
|
463
|
+
const checkAttribute = (tag, name, value, origins) => {
|
|
464
|
+
const sink = serializationSink({});
|
|
465
|
+
const element = emailElement(tag);
|
|
466
|
+
const globals = EMAIL_GLOBAL_ATTRIBUTES;
|
|
467
|
+
const attributeSpec = () => {
|
|
468
|
+
if (/^(data|aria)-[a-z0-9-]+$/.test(name)) return { type: "string" };
|
|
469
|
+
if (Object.hasOwn(globals, name)) return globals[name];
|
|
470
|
+
if (element !== void 0 && Object.hasOwn(element.attributes, name)) return element.attributes[name];
|
|
471
|
+
};
|
|
472
|
+
const spec = attributeSpec();
|
|
473
|
+
if (spec === void 0) return {
|
|
474
|
+
text: void 0,
|
|
475
|
+
diagnostics: sink.diagnostics
|
|
476
|
+
};
|
|
477
|
+
return {
|
|
478
|
+
text: attributeValue(sink, tag, name, spec, value, origins),
|
|
479
|
+
diagnostics: sink.diagnostics
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
//#endregion
|
|
483
|
+
export { UNSUBSCRIBE_LINK as _, kebab as a, INSTANCE_PATH_ATTRIBUTE as c, emailSelectionAncestry as d, emailSelectionIndex as f, BRAND_FOOTER_MARKER as g, previewEmail as h, escapeText as i, 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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as TemplateInput, n as SmlTemplate, o as defineTemplate, r as TEMPLATE_ID_PATTERN, t as EmailChannel } from "./template-DmNcoH4Q.js";
|
|
2
2
|
export { type EmailChannel, type SmlTemplate, TEMPLATE_ID_PATTERN, type TemplateInput, defineTemplate };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as JsxSourceLocation } from "./source-locations-C8wCsiAy.js";
|
|
2
|
-
import {
|
|
2
|
+
import { n as EmailDiagnostic } from "./diagnostics-D4vSLQrO.js";
|
|
3
3
|
//#region src/email/elements.d.ts
|
|
4
4
|
/** How one attribute is spelled and what values it accepts. */
|
|
5
5
|
interface EmailAttributeSpec {
|
|
@@ -641,9 +641,13 @@ type EmailNode = {
|
|
|
641
641
|
* can have its own props edited there.
|
|
642
642
|
*/
|
|
643
643
|
readonly authored: boolean;
|
|
644
|
+
/** Findings a primitive saw while building this node; see `withEmailDiagnostics`. */
|
|
645
|
+
readonly diagnostics?: readonly EmailDiagnostic[];
|
|
644
646
|
} | {
|
|
645
647
|
readonly type: "Fragment";
|
|
646
648
|
readonly children: readonly EmailNode[];
|
|
649
|
+
/** Findings a primitive saw while building this node; see `withEmailDiagnostics`. */
|
|
650
|
+
readonly diagnostics?: readonly EmailDiagnostic[];
|
|
647
651
|
} |
|
|
648
652
|
/**
|
|
649
653
|
* Compatibility markup a compiler primitive emits verbatim — conditional
|
|
@@ -672,10 +676,12 @@ type EmailComponent<P = never> = (props: P) => EmailChild;
|
|
|
672
676
|
declare const Fragment: unique symbol;
|
|
673
677
|
/** Call sites enclosing the element being built, innermost first. */
|
|
674
678
|
declare const currentEmailOrigins: () => readonly JsxSourceLocation[];
|
|
675
|
-
/**
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
declare const
|
|
679
|
+
/** A finding a primitive records before `withEmailDiagnostics` locates it. */
|
|
680
|
+
type EmailFinding = Omit<EmailDiagnostic, "origins">;
|
|
681
|
+
/** Attach findings to a primitive's output, located at the authoring call site. */
|
|
682
|
+
declare const withEmailDiagnostics: (node: EmailNode, findings: readonly EmailFinding[]) => EmailNode;
|
|
683
|
+
/** Every finding attached to a tree, in document order. */
|
|
684
|
+
declare const collectEmailDiagnostics: (node: EmailNode) => readonly EmailDiagnostic[];
|
|
679
685
|
/** Normalize actual values without interpolation or recording proxies. */
|
|
680
686
|
declare const normalizeChildren: (input: unknown) => EmailNode[];
|
|
681
687
|
/** Compatibility markup emitted by a compiler primitive, with the text it stands for. */
|
|
@@ -695,4 +701,4 @@ declare namespace JSX {
|
|
|
695
701
|
interface IntrinsicElements extends EmailIntrinsicElements {}
|
|
696
702
|
}
|
|
697
703
|
//#endregion
|
|
698
|
-
export {
|
|
704
|
+
export { emailElement as C, EmailTag as S, EmailElementSpec as _, Fragment as a, EmailLayout as b, currentEmailOrigins as c, normalizeChildren as d, rawMarkup as f, EmailAttributeSpec as g, EMAIL_GLOBAL_ATTRIBUTES as h, EmailNode as i, jsx as l, EMAIL_ELEMENTS as m, EmailComponent as n, JSX as o, withEmailDiagnostics as p, EmailFinding as r, collectEmailDiagnostics as s, EmailChild as t, jsxs as u, EmailGlobalProps as v, isUrlAttribute as w, EmailStyle as x, EmailIntrinsicElements as y };
|
|
@@ -44,16 +44,23 @@ const Fragment = Symbol.for("@samva/markup/email/Fragment");
|
|
|
44
44
|
const callSites = [];
|
|
45
45
|
/** Call sites enclosing the element being built, innermost first. */
|
|
46
46
|
const currentEmailOrigins = () => [...callSites].reverse();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
/** Attach findings to a primitive's output, located at the authoring call site. */
|
|
48
|
+
const withEmailDiagnostics = (node, findings) => {
|
|
49
|
+
if (findings.length === 0 || node.type !== "Element" && node.type !== "Fragment") return node;
|
|
50
|
+
const origins = currentEmailOrigins();
|
|
51
|
+
return {
|
|
52
|
+
...node,
|
|
53
|
+
diagnostics: [...node.diagnostics ?? [], ...findings.map((finding) => ({
|
|
54
|
+
...finding,
|
|
55
|
+
origins
|
|
56
|
+
}))]
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** Every finding attached to a tree, in document order. */
|
|
60
|
+
const collectEmailDiagnostics = (node) => {
|
|
61
|
+
if (node.type !== "Element" && node.type !== "Fragment") return [];
|
|
62
|
+
return [...node.diagnostics ?? [], ...node.children.flatMap(collectEmailDiagnostics)];
|
|
54
63
|
};
|
|
55
|
-
/** Take and clear the findings primitives reported during a render. */
|
|
56
|
-
const drainEmailDiagnostics = () => reported.splice(0);
|
|
57
64
|
/** Normalize actual values without interpolation or recording proxies. */
|
|
58
65
|
const normalizeChildren = (input) => {
|
|
59
66
|
if (input === null || input === void 0 || typeof input === "boolean") return [];
|
|
@@ -93,4 +100,4 @@ const jsx = (type, props) => {
|
|
|
93
100
|
};
|
|
94
101
|
const jsxs = jsx;
|
|
95
102
|
//#endregion
|
|
96
|
-
export { jsx as a, rawMarkup as c, completeJsxSourceLocation as d,
|
|
103
|
+
export { jsx as a, rawMarkup as c, completeJsxSourceLocation as d, currentEmailOrigins as i, withEmailDiagnostics as l, Fragment as n, jsxs as o, collectEmailDiagnostics as r, normalizeChildren as s, EmailNode as t, JSX_SOURCE_LOCATION_PROP as u };
|