@samva/markup 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +11 -4
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +89 -5
  4. package/dist/assets-BSpgZOSR.js +61 -0
  5. package/dist/assets-k7_v3IOJ.d.ts +39 -0
  6. package/dist/{assets-CS5Xa_mr.d.ts → brand-Bxb6Sm7y.d.ts} +34 -41
  7. package/dist/brand.d.ts +2 -2
  8. package/dist/brand.js +3 -3
  9. package/dist/compatibility.d.ts +15 -5
  10. package/dist/compatibility.js +48 -5
  11. package/dist/compile-C1GWLQ5C.js +490 -0
  12. package/dist/compile-CIeuGmP-.d.ts +98 -0
  13. package/dist/{compiled-template-Bc4Dq9zy.d.ts → compiled-template-CPDHvFlt.d.ts} +157 -6
  14. package/dist/compiler.d.ts +4 -89
  15. package/dist/compiler.js +8 -486
  16. package/dist/components-DNBCz20_.js +3267 -0
  17. package/dist/{css-color-DLM12Osu.js → dark-scheme-BGDCCaUq.js} +12 -1
  18. package/dist/{diagnostic-codes-DZKb0Xcg.js → diagnostic-codes-BCwfUgnF.js} +33 -36
  19. package/dist/diagnostic-model-DJtqbETd.js +36 -0
  20. package/dist/{diagnostics-D4vSLQrO.d.ts → diagnostics-C3TSx6SU.d.ts} +22 -2
  21. package/dist/diagnostics.d.ts +33 -1
  22. package/dist/diagnostics.js +2 -1
  23. package/dist/edit.d.ts +15 -4
  24. package/dist/edit.js +67 -4
  25. package/dist/email/jsx-dev-runtime.d.ts +2 -2
  26. package/dist/email/jsx-runtime.d.ts +1 -1
  27. package/dist/email.d.ts +6 -2
  28. package/dist/email.js +6 -2
  29. package/dist/index.d.ts +1 -1
  30. package/dist/{jsx-runtime-DL8NVF1f.d.ts → jsx-runtime-HHfrVZEf.d.ts} +1 -2
  31. package/dist/markdown.d.ts +69 -0
  32. package/dist/markdown.js +95 -0
  33. package/dist/parse-Bex7QFqj.js +921 -0
  34. package/dist/{preview-rNoJBEIY.d.ts → preview-DRLWgmfg.d.ts} +3 -3
  35. package/dist/profile-DFk4gs7o.js +56 -0
  36. package/dist/{profile-JTvgCD6X.js → project-B6D64SZS.js} +497 -234
  37. package/dist/render.d.ts +8 -8
  38. package/dist/render.js +1 -2
  39. package/dist/{css-B_aVKrDo.js → selectors-CKGuc9MO.js} +158 -4
  40. package/dist/sms.d.ts +1 -1
  41. package/dist/{template-DmNcoH4Q.d.ts → template-4Dse7_OC.d.ts} +1 -1
  42. package/dist/{theme-css-P9F5S7Ic.js → theme-css-BujDuuEA.js} +1 -2
  43. package/dist/whatsapp.d.ts +1 -1
  44. package/generated/contract.json +41 -1
  45. package/generated/reference.md +97 -89
  46. package/package.json +15 -4
  47. package/dist/brand-CIiEqkU7.js +0 -193
  48. package/dist/brand-plugin-Dj9SMpfS.d.ts +0 -33
  49. package/dist/components-C5850-Qg.js +0 -409
  50. package/dist/dark-scheme-CbCwGDWH.js +0 -12
  51. package/dist/html-DJUK2YJF.js +0 -483
  52. package/dist/render-template-DwPDm3Hq.js +0 -1226
  53. package/dist/selectors-CYWCoxZr.js +0 -157
  54. package/dist/source-locations-C8wCsiAy.d.ts +0 -22
@@ -1,193 +0,0 @@
1
- import { _ as UNSUBSCRIBE_LINK, g as BRAND_FOOTER_MARKER, r as escapeAttribute } from "./html-DJUK2YJF.js";
2
- import { f as samvaBrandPlugin } from "./render-template-DwPDm3Hq.js";
3
- import { a as jsx, c as rawMarkup, n as Fragment } from "./jsx-runtime-qL19u3Fy.js";
4
- //#region src/email/assets.ts
5
- /** Files a template may import, and what they are served as. */
6
- const ASSET_CONTENT_TYPES = {
7
- ".png": "image/png",
8
- ".jpg": "image/jpeg",
9
- ".jpeg": "image/jpeg",
10
- ".gif": "image/gif",
11
- ".webp": "image/webp",
12
- ".avif": "image/avif",
13
- ".svg": "image/svg+xml",
14
- ".ico": "image/x-icon",
15
- ".woff": "font/woff",
16
- ".woff2": "font/woff2",
17
- ".ttf": "font/ttf",
18
- ".otf": "font/otf",
19
- ".pdf": "application/pdf",
20
- ".txt": "text/plain"
21
- };
22
- const extensionOf = (path) => {
23
- const dot = path.lastIndexOf(".");
24
- return dot < 0 ? "" : path.slice(dot).toLowerCase();
25
- };
26
- /** True when the compiler turns this import into a content-addressed asset. */
27
- const isAssetPath = (path) => Object.hasOwn(ASSET_CONTENT_TYPES, extensionOf(path));
28
- const hex = (buffer) => Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
29
- /** Address one file by its content. */
30
- const assetEntry = async (path, bytes) => {
31
- const extension = extensionOf(path);
32
- const view = new Uint8Array(bytes.byteLength);
33
- view.set(bytes);
34
- const digest = hex(await crypto.subtle.digest("SHA-256", view));
35
- return {
36
- path,
37
- digest,
38
- bytes: bytes.byteLength,
39
- contentType: ASSET_CONTENT_TYPES[extension] ?? "application/octet-stream",
40
- fileName: `${digest}${extension}`
41
- };
42
- };
43
- /**
44
- * The URL an asset has under a base. The base is a build input: publication
45
- * passes its hosted origin, and a local export passes whatever it will serve
46
- * the exported files from.
47
- */
48
- const assetUrl = (entry, base) => `${base.replace(/\/+$/, "")}/${entry.fileName}`;
49
- const EXTERNAL_IMAGE = /^https:\/\//i;
50
- /** Record, but never fetch, a URL the template points at. */
51
- const isExternalAsset = (reference) => EXTERNAL_IMAGE.test(reference);
52
- /**
53
- * Reject an asset base that would produce URLs a client will not load. The
54
- * relative forms are the ones a page on disk resolves against its own
55
- * directory, which is what a local export writes.
56
- */
57
- const checkAssetBase = (base) => /^(https:\/\/|\.{1,2}\/|\/)/i.test(base) ? [] : [{
58
- code: "invalid-asset-base",
59
- severity: "error",
60
- message: `Asset base ${JSON.stringify(base)} must be an https URL, an absolute path, or a relative path for a local export.`,
61
- origins: []
62
- }];
63
- //#endregion
64
- //#region src/email/brand.ts
65
- /** Every class the brand components use, compiled for any project that imports them. */
66
- const BRAND_COMPONENT_CLASSES = [
67
- "dark:hidden!",
68
- "hidden",
69
- "dark:block!",
70
- "text-xs",
71
- "text-muted-foreground",
72
- "text-center",
73
- "font-semibold",
74
- "underline"
75
- ];
76
- const addressLines = (address) => [
77
- address.street1,
78
- address.street2,
79
- [
80
- address.city,
81
- address.region,
82
- address.postalCode
83
- ].filter((part) => part.trim() !== "").join(", "),
84
- address.country
85
- ].filter((line) => line !== void 0 && line.trim() !== "");
86
- const classes = (...names) => {
87
- const joined = names.filter((name) => name !== void 0 && name !== "").join(" ");
88
- return joined === "" ? void 0 : joined;
89
- };
90
- const line = (children, last) => jsx("p", {
91
- style: { margin: last ? "0" : "0 0 8px 0" },
92
- children
93
- });
94
- /** The alignment a footer `className` asks for; the footer centers when it names none. */
95
- const footerAlignment = (className) => /(?:^|\s)text-(left|center|right)(?:\s|$)/.exec(className ?? "")?.[1] ?? "center";
96
- /** Bind `BrandLogo` and `BrandFooter` to one brand, marked as the plugin names. */
97
- const brandComponents = (brand, plugin = samvaBrandPlugin) => {
98
- const assets = brand.assets ?? {};
99
- const footer = brand.footer ?? {};
100
- const BrandLogo = ({ width = 120, alt, align, className }) => {
101
- if (assets.logoUrl === void 0) return null;
102
- const label = alt ?? footer.companyName ?? "";
103
- const image = (src, imageClass, imageAlt) => jsx("img", {
104
- src,
105
- alt: imageAlt,
106
- width,
107
- border: 0,
108
- ...align === void 0 ? {} : { align },
109
- className: classes(imageClass, className),
110
- style: {
111
- display: "block",
112
- outline: "none",
113
- border: "none",
114
- height: "auto"
115
- }
116
- });
117
- if (assets.logoDarkUrl === void 0) return image(assets.logoUrl, void 0, label);
118
- return jsx(Fragment, { children: [image(assets.logoUrl, "dark:hidden!", label), jsx("div", {
119
- className: "hidden dark:block!",
120
- style: {
121
- msoHide: "all",
122
- maxHeight: 0,
123
- overflow: "hidden"
124
- },
125
- children: image(assets.logoDarkUrl, void 0, "")
126
- })] });
127
- };
128
- const BrandFooter = ({ unsubscribeLabel = "Unsubscribe", className }) => {
129
- const items = [];
130
- const separator = () => items.length === 0 ? [] : [" · "];
131
- if (footer.companyName !== void 0 && footer.companyName.trim() !== "") items.push(jsx("span", {
132
- className: "font-semibold",
133
- children: footer.companyName
134
- }));
135
- for (const link of footer.socialLinks ?? []) items.push(...separator(), jsx("a", {
136
- href: link.url,
137
- className: "text-muted-foreground underline",
138
- children: link.label
139
- }));
140
- items.push(jsx("span", {
141
- [plugin.unsubscribeRowAttribute]: "",
142
- [BRAND_FOOTER_MARKER]: true,
143
- children: [...items.length === 0 ? [] : [rawMarkup(" · ", "\n")], unsubscribeLink(unsubscribeLabel, plugin)]
144
- }));
145
- const address = footer.address === void 0 ? [] : addressLines(footer.address);
146
- const alignment = footerAlignment(className);
147
- return jsx("table", {
148
- role: "presentation",
149
- width: "100%",
150
- border: 0,
151
- cellpadding: 0,
152
- cellspacing: 0,
153
- [plugin.footerAttribute]: "",
154
- [BRAND_FOOTER_MARKER]: true,
155
- children: jsx("tbody", { children: jsx("tr", { children: jsx("td", {
156
- align: alignment,
157
- className: classes("text-xs text-muted-foreground", alignment === "center" ? "text-center" : void 0, className),
158
- style: { padding: "32px 16px" },
159
- children: [line(items, address.length === 0), ...address.length === 0 ? [] : [line([address.join(", ")], true)]]
160
- }) }) })
161
- });
162
- };
163
- return {
164
- BrandLogo,
165
- BrandFooter
166
- };
167
- };
168
- /**
169
- * The unsubscribe link. Its href is the placeholder, which no author-written
170
- * link may carry: the serializer accepts it only on this node, and writes the
171
- * plugin's no-track marker on it so click tracking never rewrites it.
172
- */
173
- const unsubscribeLink = (label, plugin) => jsx("a", {
174
- href: plugin.unsubscribeUrlPlaceholder,
175
- className: "text-muted-foreground underline",
176
- [UNSUBSCRIBE_LINK]: true,
177
- children: label
178
- });
179
- /** Read the plugin's footer markers from rendered HTML. */
180
- const renderedFooterMarkers = (html, plugin = samvaBrandPlugin) => ({
181
- footer: html.includes(`${plugin.footerAttribute}=""`),
182
- unsubscribeLink: html.includes(`href="${escapeAttribute(plugin.unsubscribeUrlPlaceholder)}"`)
183
- });
184
- /**
185
- * Drop the unsubscribe row from a rendered message, for a send that resolved
186
- * no unsubscribe group: there is no link to put there, so the row goes.
187
- */
188
- const withoutUnsubscribeRow = (rendered, plugin = samvaBrandPlugin) => ({
189
- html: rendered.html?.replace(new RegExp(`<(span|tr) ${plugin.unsubscribeRowAttribute}=""[^>]*>[\\s\\S]*?</\\1>`, "g"), ""),
190
- text: rendered.text?.split("\n").filter((textLine) => !textLine.includes(plugin.unsubscribeUrlPlaceholder)).join("\n")
191
- });
192
- //#endregion
193
- export { ASSET_CONTENT_TYPES as a, checkAssetBase as c, withoutUnsubscribeRow as i, isAssetPath as l, brandComponents as n, assetEntry as o, renderedFooterMarkers as r, assetUrl as s, BRAND_COMPONENT_CLASSES as t, isExternalAsset as u };
@@ -1,33 +0,0 @@
1
- //#region src/email/brand-plugin.d.ts
2
- /**
3
- * How a template reaches its brand and how the footer `BrandFooter` renders is
4
- * marked for the send path that delivers it. The compiler and the IR renderer
5
- * read every brand import, footer marker and unsubscribe link from the plugin
6
- * a build passes; `samvaBrandPlugin` is the default.
7
- */
8
- interface BrandPlugin {
9
- /**
10
- * The module and stylesheet specifier a template imports its brand from.
11
- * `<specifier>/<slug>` names one brand; the bare specifier names the default.
12
- */
13
- readonly specifier: string;
14
- /** Marks the block `BrandFooter` renders, so the send path finds a template's own footer. */
15
- readonly footerAttribute: `data-${string}`;
16
- /** Marks the footer row that carries the unsubscribe link, so a send can drop it. */
17
- readonly unsubscribeRowAttribute: `data-${string}`;
18
- /** The unsubscribe link's href; the send path substitutes each delivery's URL for it. */
19
- readonly unsubscribeUrlPlaceholder: string;
20
- /**
21
- * Written on the unsubscribe link, so the send path delivers it without
22
- * click tracking: a rewritten link would carry another recipient's token.
23
- */
24
- readonly noTrackAttribute: `data-${string}`;
25
- }
26
- /** The brand a specifier names: `undefined` slug means the default brand; `undefined` when it names none. */
27
- declare const parseBrandSpecifier: (specifier: string, options?: {
28
- readonly brandPlugin?: BrandPlugin | undefined;
29
- }) => {
30
- readonly slug: string | undefined;
31
- } | undefined;
32
- //#endregion
33
- export { parseBrandSpecifier as n, BrandPlugin as t };
@@ -1,409 +0,0 @@
1
- import { i as escapeText, r as escapeAttribute } from "./html-DJUK2YJF.js";
2
- import { a as jsx, c as rawMarkup, l as withEmailDiagnostics, n as Fragment, s as normalizeChildren, t as EmailNode } from "./jsx-runtime-qL19u3Fy.js";
3
- //#region src/email/primitive-css.ts
4
- /** The classes the primitives put on the elements they generate. */
5
- const PRIMITIVE_CLASSES = {
6
- email: "sml-email",
7
- button: "sml-button",
8
- link: "sml-link",
9
- divider: "sml-divider",
10
- stack: "sml-stack"
11
- };
12
- const PRIMITIVE_CSS = `@layer components {
13
- .${PRIMITIVE_CLASSES.email} {
14
- color: var(--color-foreground);
15
- font-family: var(--font-sans);
16
- padding-left: 16px;
17
- padding-right: 16px;
18
- }
19
- .${PRIMITIVE_CLASSES.button} {
20
- background-color: var(--color-primary);
21
- color: var(--color-primary-foreground);
22
- font-family: var(--font-sans);
23
- border-radius: var(--radius);
24
- }
25
- .${PRIMITIVE_CLASSES.link} {
26
- color: var(--color-primary);
27
- text-decoration: underline;
28
- }
29
- .${PRIMITIVE_CLASSES.divider} {
30
- border-top: 1px solid var(--color-border);
31
- }
32
- }
33
- @media only screen and (max-width: 599px) {
34
- .${PRIMITIVE_CLASSES.stack} {
35
- display: block;
36
- width: 100%;
37
- max-width: 100%;
38
- }
39
- }
40
- `;
41
- //#endregion
42
- //#region src/email/components.ts
43
- /** Marks the hidden preview block so the renderer can read it back and the text lane can keep it. */
44
- const PREHEADER_ATTRIBUTE = "data-samva-preheader";
45
- const PREHEADER_PADDING_ATTRIBUTE = "data-samva-preheader-padding";
46
- const PREHEADER_TARGET_LENGTH = 200;
47
- const PREHEADER_PADDING = "\xA0‌​‍‎‏";
48
- const presentation = (table, row, rowProps = {}) => jsx("table", {
49
- role: "presentation",
50
- border: 0,
51
- cellpadding: 0,
52
- cellspacing: 0,
53
- ...table,
54
- children: jsx("tbody", { children: jsx("tr", {
55
- ...rowProps,
56
- children: row
57
- }) })
58
- });
59
- /** A primitive's own class, then the author's. */
60
- const classNames = (own, authored) => authored === void 0 || authored.trim() === "" ? own : `${own} ${authored}`;
61
- const optionalNumber = (found, component, name, value, bounds = {}) => {
62
- if (value === void 0) return void 0;
63
- if (typeof value === "number" && Number.isFinite(value) && (bounds.min === void 0 || value >= bounds.min)) return value;
64
- found.push({
65
- code: "invalid-number-prop",
66
- severity: "error",
67
- message: `<${component} ${name}> must be a finite number${bounds.min === void 0 ? "" : ` of at least ${bounds.min}`}.`
68
- });
69
- };
70
- /** The hidden inbox preview block. Padding characters stop the client borrowing body copy. */
71
- const preheaderNode = (text) => jsx("div", {
72
- [PREHEADER_ATTRIBUTE]: text,
73
- style: {
74
- display: "none",
75
- overflow: "hidden",
76
- lineHeight: "1px",
77
- opacity: 0,
78
- maxHeight: 0,
79
- maxWidth: 0
80
- },
81
- children: [text, text.length >= PREHEADER_TARGET_LENGTH ? null : jsx("div", {
82
- [PREHEADER_PADDING_ATTRIBUTE]: true,
83
- children: PREHEADER_PADDING.repeat(Math.ceil((PREHEADER_TARGET_LENGTH - text.length) / 7))
84
- })]
85
- });
86
- /**
87
- * Document shell: the head every target client needs, a body painted with the page color, and
88
- * the content cell. The content cell sets the theme's text color and body font
89
- * (`--color-foreground`, `--font-sans`) for everything inside it, and pads the content off the
90
- * screen edge (`gutter`).
91
- */
92
- const Email = ({ className, lang = "en", dir = "ltr", title, backgroundColor = "var(--color-background)", gutter, style, children }) => {
93
- const found = [];
94
- const safeGutter = optionalNumber(found, "Email", "gutter", gutter, { min: 0 });
95
- const cellStyle = {
96
- ...safeGutter === void 0 ? {} : {
97
- paddingLeft: `${safeGutter}px`,
98
- paddingRight: `${safeGutter}px`
99
- },
100
- ...style
101
- };
102
- const document = jsx("html", {
103
- lang,
104
- dir,
105
- xmlns: "http://www.w3.org/1999/xhtml",
106
- "xmlns:o": "urn:schemas-microsoft-com:office:office",
107
- children: [jsx("head", { children: [
108
- jsx("meta", { charset: "utf-8" }),
109
- jsx("meta", {
110
- "http-equiv": "Content-Type",
111
- content: "text/html; charset=UTF-8"
112
- }),
113
- jsx("meta", {
114
- name: "viewport",
115
- content: "width=device-width, initial-scale=1"
116
- }),
117
- jsx("meta", { name: "x-apple-disable-message-reformatting" }),
118
- jsx("meta", {
119
- name: "color-scheme",
120
- content: "light dark"
121
- }),
122
- jsx("meta", {
123
- name: "supported-color-schemes",
124
- content: "light dark"
125
- }),
126
- title === void 0 ? null : jsx("title", { children: title }),
127
- rawMarkup("<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->")
128
- ] }), jsx("body", {
129
- style: {
130
- margin: 0,
131
- padding: 0,
132
- width: "100%",
133
- backgroundColor
134
- },
135
- children: [presentation({
136
- align: "center",
137
- width: "100%",
138
- bgcolor: backgroundColor,
139
- style: {
140
- width: "100%",
141
- backgroundColor
142
- }
143
- }, jsx("td", {
144
- className: classNames(PRIMITIVE_CLASSES.email, className),
145
- ...Object.keys(cellStyle).length === 0 ? {} : { style: cellStyle },
146
- children
147
- }))]
148
- })]
149
- });
150
- return withEmailDiagnostics(document, found);
151
- };
152
- /** A pixel width as `width: 100%` capped at it, so a fixed-width table still fits a phone. */
153
- const fluidWidth = (width) => {
154
- const pixels = typeof width === "number" ? width : /^\d+(?:\.\d+)?(?:px)?$/.test(width) ? width : void 0;
155
- if (pixels === void 0) return void 0;
156
- return {
157
- width: "100%",
158
- maxWidth: typeof pixels === "number" ? `${pixels}px` : `${Number.parseFloat(pixels)}px`
159
- };
160
- };
161
- /** A full-width band of content, as the single-cell table every client agrees on. */
162
- const Section = ({ className, align = "center", width = "100%", bgcolor, style, tableStyle, children }) => {
163
- const fluid = fluidWidth(width);
164
- const table = fluid === void 0 ? tableStyle : {
165
- ...fluid,
166
- ...tableStyle
167
- };
168
- return presentation({
169
- align,
170
- width,
171
- ...bgcolor === void 0 ? {} : { bgcolor },
172
- ...table === void 0 ? {} : { style: table }
173
- }, jsx("td", {
174
- className,
175
- ...style === void 0 ? {} : { style },
176
- children
177
- }));
178
- };
179
- /** The equal share of a row that each of `count` cells takes, whole percent rounded down. */
180
- const equalColumnWidth = (count) => `${Math.floor(100 / count)}%`;
181
- /**
182
- * Give a `Column` that states no width its equal share, keeping the primitive's attribute order,
183
- * and the stacking class when its row stacks.
184
- */
185
- const withColumnLayout = (node, width, stack) => {
186
- if (node.type === "Fragment") return EmailNode.Fragment({ children: node.children.map((child) => withColumnLayout(child, width, stack)) });
187
- if (node.type !== "Element" || node.tag !== "td") return node;
188
- const props = {};
189
- const sized = width === void 0 || node.props.width !== void 0;
190
- for (const [key, value] of Object.entries(node.props)) {
191
- if (key === "style" && !sized) props.width = width;
192
- props[key] = value;
193
- }
194
- if (!sized && props.width === void 0) props.width = width;
195
- if (stack) props.className = classNames(PRIMITIVE_CLASSES.stack, typeof props.className === "string" ? props.className : void 0);
196
- return EmailNode.Element({
197
- ...node,
198
- props
199
- });
200
- };
201
- /** The cell between two `Column`s: `gap` wide in a row, `gap` tall once the row stacks. */
202
- const gapCell = (gap, stack) => jsx("td", {
203
- ...stack ? { className: PRIMITIVE_CLASSES.stack } : {},
204
- width: gap,
205
- style: {
206
- width: `${gap}px`,
207
- ...stack ? { height: `${gap}px` } : {},
208
- fontSize: "1px",
209
- lineHeight: "1px"
210
- },
211
- children: "​"
212
- });
213
- /** Cells with a gap cell between each two that render something. */
214
- const withGaps = (cells, gap, stack) => {
215
- if (gap === void 0 || gap === 0) return cells;
216
- const output = [];
217
- for (const cell of cells) {
218
- if (cell.type === "Text" && cell.value.trim() === "") {
219
- output.push(cell);
220
- continue;
221
- }
222
- if (output.some((placed) => placed.type !== "Text" || placed.value.trim() !== "")) output.push(gapCell(gap, stack));
223
- output.push(cell);
224
- }
225
- return output;
226
- };
227
- /**
228
- * A row of side-by-side `Column`s that stacks on a narrow viewport (`stack`). No flex or grid
229
- * conversion happens here. With `each` it repeats its cells over a list, `per` to a row, and
230
- * splits the row equally between cells that state no width.
231
- */
232
- const Columns = ({ className, align = "center", width = "100%", style, each, per, stack = true, gap, children }) => {
233
- const table = {
234
- className,
235
- align,
236
- width,
237
- ...style === void 0 ? {} : { style }
238
- };
239
- const found = [];
240
- const safeGap = optionalNumber(found, "Columns", "gap", gap, { min: 0 });
241
- if (each === void 0 || typeof children !== "function") return withEmailDiagnostics(presentation(table, withGaps(normalizeChildren(children).map((cell) => withColumnLayout(cell, void 0, stack)), safeGap, stack), { style: { width: "100%" } }), found);
242
- if (per !== void 0 && (!Number.isInteger(per) || per < 1)) found.push({
243
- code: "invalid-columns-per",
244
- severity: "error",
245
- message: "<Columns per> must be a positive integer."
246
- });
247
- const size = per !== void 0 && Number.isInteger(per) && per >= 1 ? per : void 0;
248
- const chunks = [];
249
- if (size === void 0) chunks.push(each);
250
- else for (let at = 0; at < each.length; at += size) chunks.push(each.slice(at, at + size));
251
- const share = equalColumnWidth(size ?? Math.max(each.length, 1));
252
- const rows = jsx(Fragment, { children: chunks.map((chunk) => presentation(table, withGaps(chunk.flatMap((item, index) => normalizeChildren(children(item, index)).map((cell) => withColumnLayout(cell, share, stack))), safeGap, stack), { style: { width: "100%" } })) });
253
- return withEmailDiagnostics(rows, found);
254
- };
255
- /** One cell of a `Columns` row. */
256
- const Column = ({ className, align, valign, width, style, children }) => jsx("td", {
257
- className,
258
- ...align === void 0 ? {} : { align },
259
- ...valign === void 0 ? {} : { valign },
260
- ...width === void 0 ? {} : { width },
261
- ...style === void 0 ? {} : { style },
262
- children
263
- });
264
- /** Average advance of a bold sans-serif glyph, as a fraction of the font size. */
265
- const BOLD_GLYPH_ADVANCE = .6;
266
- const invalidButtonNumber = (found, name) => {
267
- found.push({
268
- code: "invalid-button-number",
269
- severity: "error",
270
- message: `<Button ${name}> must be a finite number.`
271
- });
272
- };
273
- const buttonNumber = (found, name, value, fallback) => {
274
- if (typeof value === "number" && Number.isFinite(value)) return value;
275
- invalidButtonNumber(found, name);
276
- return fallback;
277
- };
278
- const optionalButtonNumber = (found, name, value) => {
279
- if (value === void 0) return void 0;
280
- if (typeof value === "number" && Number.isFinite(value)) return value;
281
- invalidButtonNumber(found, name);
282
- };
283
- const optionalPositiveButtonNumber = (found, name, value) => {
284
- if (value === void 0) return void 0;
285
- if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
286
- found.push({
287
- code: "invalid-button-number",
288
- severity: "error",
289
- message: `<Button ${name}> must be a positive finite number.`
290
- });
291
- };
292
- /**
293
- * A link styled as a button, with a VML fallback so classic Outlook draws the shape. Its fill,
294
- * label color, font and corners default to the theme (`bg-primary`, `text-primary-foreground`,
295
- * `font-sans`, `rounded`), so a class on the button restyles it. Classic Outlook draws the VML
296
- * shape from the props alone: set `backgroundColor`, `color` and `borderRadius` when the button
297
- * does not use the theme's primary fill.
298
- */
299
- const Button = ({ className, href, width, height, paddingX = 16, backgroundColor, color, borderRadius, borderColor, borderWidth = 0, fontSize = 16, fontFamily, align = "left", style, children }) => {
300
- const found = [];
301
- const safeWidth = optionalButtonNumber(found, "width", width);
302
- const safeHeight = optionalPositiveButtonNumber(found, "height", height);
303
- const safePaddingX = buttonNumber(found, "paddingX", paddingX, 16);
304
- const safeBorderRadius = optionalButtonNumber(found, "borderRadius", borderRadius);
305
- const safeBorderWidth = buttonNumber(found, "borderWidth", borderWidth, 0);
306
- const safeFontSize = buttonNumber(found, "fontSize", fontSize, 16);
307
- const label = typeof children === "string" ? children : void 0;
308
- const fallback = safeHeight !== void 0 && label !== void 0;
309
- if (!fallback) found.push({
310
- code: "button-outlook-fallback",
311
- severity: "warning",
312
- message: "<Button> draws its classic Outlook shape from VML, which needs a height and a plain string label. Without both, classic Windows Outlook shows an unstyled link.",
313
- clients: ["outlook-windows"]
314
- });
315
- const arcsize = safeHeight === void 0 ? 0 : Math.round((safeBorderRadius ?? 0) / safeHeight * 100);
316
- const fallbackWidth = safeWidth ?? Math.ceil((label?.length ?? 0) * safeFontSize * BOLD_GLYPH_ADVANCE) + 2 * safePaddingX;
317
- const fill = backgroundColor ?? "var(--color-primary)";
318
- const ink = color ?? "var(--color-primary-foreground)";
319
- const family = fontFamily ?? "var(--font-sans)";
320
- const button = presentation({
321
- width: "100%",
322
- style: { borderCollapse: "collapse" }
323
- }, jsx("td", {
324
- align,
325
- children: [fallback ? rawMarkup(`<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeAttribute(href)}" style="height:${safeHeight}px;v-text-anchor:middle;width:${fallbackWidth}px;" arcsize="${arcsize}%" ${borderColor === void 0 ? "stroke=\"false\"" : `strokecolor="${escapeAttribute(borderColor)}" strokeweight="${safeBorderWidth}px"`} fillcolor="${escapeAttribute(fill)}"><w:anchorlock/><center style="color:${escapeAttribute(ink)};font-family:${escapeAttribute(family)};font-size:${safeFontSize}px;font-weight:bold;">${escapeText(label)}</center></v:roundrect><![endif]-->`) : null, jsx("a", {
326
- className: classNames(PRIMITIVE_CLASSES.button, className),
327
- href,
328
- target: "_blank",
329
- rel: "noopener noreferrer",
330
- style: {
331
- ...backgroundColor === void 0 ? {} : { backgroundColor },
332
- ...color === void 0 ? {} : { color },
333
- ...safeBorderRadius === void 0 ? {} : { borderRadius: `${safeBorderRadius}px` },
334
- display: "inline-block",
335
- ...fontFamily === void 0 ? {} : { fontFamily },
336
- fontSize: `${safeFontSize}px`,
337
- fontWeight: "bold",
338
- lineHeight: safeHeight === void 0 ? "120%" : `${safeHeight - 2 * safeBorderWidth}px`,
339
- textAlign: "center",
340
- textDecoration: "none",
341
- ...safeWidth === void 0 ? { padding: `0 ${safePaddingX}px` } : {
342
- width: `${safeWidth}px`,
343
- maxWidth: "100%"
344
- },
345
- ...borderColor === void 0 ? {} : { border: `${safeBorderWidth}px solid ${borderColor}` },
346
- ...fallback ? { msoHide: "all" } : {},
347
- ...style
348
- },
349
- children
350
- })]
351
- }));
352
- return withEmailDiagnostics(button, found);
353
- };
354
- /** Vertical space that survives clients which collapse margins. */
355
- const Spacer = ({ height }) => presentation({ width: "100%" }, jsx("td", {
356
- height,
357
- style: {
358
- height: `${height}px`,
359
- lineHeight: `${height}px`,
360
- fontSize: "1px"
361
- },
362
- children: "​"
363
- }));
364
- /**
365
- * A horizontal rule drawn as a cell border, which every target client renders. It defaults to a
366
- * 1px line in the theme's `--color-border`.
367
- */
368
- const Divider = ({ className, color, thickness, style }) => presentation({ width: "100%" }, jsx("td", {
369
- className: classNames(PRIMITIVE_CLASSES.divider, className),
370
- style: {
371
- ...color === void 0 && thickness === void 0 ? {} : { borderTop: `${thickness ?? 1}px solid ${color ?? "var(--color-border)"}` },
372
- fontSize: "1px",
373
- lineHeight: "1px",
374
- ...style
375
- },
376
- children: "​"
377
- }));
378
- /** An image with the width/height attributes Outlook sizes from before CSS applies. */
379
- const Image = ({ className, src, alt, width, height, align, style }) => jsx("img", {
380
- className,
381
- src,
382
- alt,
383
- border: 0,
384
- ...width === void 0 ? {} : { width },
385
- ...height === void 0 ? {} : { height },
386
- ...align === void 0 ? {} : { align },
387
- style: {
388
- display: "block",
389
- outline: "none",
390
- border: "none",
391
- textDecoration: "none",
392
- maxWidth: "100%",
393
- ...style
394
- }
395
- });
396
- /**
397
- * A text link that opens outside the client and cannot reach back at the opener. It defaults to
398
- * the theme's `--color-primary`, underlined.
399
- */
400
- const Link = ({ className, href, target = "_blank", rel = "noopener noreferrer", style, children }) => jsx("a", {
401
- className: classNames(PRIMITIVE_CLASSES.link, className),
402
- href,
403
- target,
404
- rel,
405
- ...style === void 0 ? {} : { style },
406
- children
407
- });
408
- //#endregion
409
- export { Email as a, PREHEADER_ATTRIBUTE as c, equalColumnWidth as d, preheaderNode as f, Divider as i, Section as l, PRIMITIVE_CSS as m, Column as n, Image as o, PRIMITIVE_CLASSES as p, Columns as r, Link as s, Button as t, Spacer as u };
@@ -1,12 +0,0 @@
1
- //#region src/email/dark-scheme.ts
2
- /** The media query a theme's dark-mode block and every generated dark-mode rule sit in. */
3
- const DARK_SCHEME_MEDIA = "@media (prefers-color-scheme: dark)";
4
- const DARK_SCHEME_QUERY = /^(?:only\s+)?(?:screen\s+and\s+)?\(\s*prefers-color-scheme\s*:\s*dark\s*\)$/i;
5
- /** Whether a `@media` prelude is exactly the dark-mode query. */
6
- const isDarkSchemeQuery = (prelude) => DARK_SCHEME_QUERY.test(prelude.trim());
7
- /** Whether a rule's enclosing conditions are exactly the dark-mode query. */
8
- const isDarkSchemeCondition = (conditions) => conditions.length === 1 && /^@media\s/i.test(conditions[0]) && isDarkSchemeQuery(conditions[0].replace(/^@media\s+/i, ""));
9
- /** Whether any enclosing condition is the dark-mode query, as in a size query nested inside it. */
10
- const withinDarkScheme = (conditions) => conditions.some((condition) => isDarkSchemeCondition([condition]));
11
- //#endregion
12
- export { withinDarkScheme as i, isDarkSchemeCondition as n, isDarkSchemeQuery as r, DARK_SCHEME_MEDIA as t };