@sdxc/mail 0.0.0-pre.1

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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Derives the plain-text alternative of an email from its rendered HTML, so every
3
+ * message ships both parts without a second authoring step. The conversion is
4
+ * heuristic by design: it keeps link targets and block structure, and drops
5
+ * anything a reader cannot act on.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ /** HTML comments, which never carry reader-visible content. */
11
+ const COMMENT = /<!--[\s\S]*?-->/g;
12
+ /** A document type declaration, which describes the document format. */
13
+ const DOCTYPE = /<!doctype[^>]*>/gi;
14
+ /**
15
+ * Blocks hidden from sighted readers with `display:none`, such as a preheader.
16
+ * Non-greedy matching closes at the first same-tag closing tag, which is correct
17
+ * for the hidden single-element blocks email layouts use.
18
+ */
19
+ const HIDDEN_BLOCK = /<(div|span|p)\b[^>]*display\s*:\s*none[^>]*>[\s\S]*?<\/\1>/gi;
20
+ /**
21
+ * Blocks marked `data-skip-in-text`, the explicit signal an author gives that an
22
+ * element belongs to the HTML part alone, covering visible content such as a
23
+ * decorative rule, a spacer, or a logo's alt text.
24
+ */
25
+ const SKIPPED_BLOCK = /<(\w+)\b[^>]*\bdata-skip-in-text\b[^>]*>[\s\S]*?<\/\1>/gi;
26
+ /** The same marker on an element that closes itself, which has nothing to drop between tags. */
27
+ const SKIPPED_VOID = /<\w+\b[^>]*\bdata-skip-in-text\b[^>]*\/?>/gi;
28
+ /** Elements whose content belongs to the document structure. */
29
+ const DROPPED_BLOCK = /<(head|script|style|title)\b[^>]*>[\s\S]*?<\/\1>/gi;
30
+ /** Explicit line breaks, the one inline element that carries layout meaning. */
31
+ const LINE_BREAK = /<br\s*\/?>/gi;
32
+ /** A link with its target, captured as double-quoted, single-quoted, or bare. */
33
+ const ANCHOR = /<a\b[^>]*href\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi;
34
+ /** End of a list item, which separates entries with a single newline. */
35
+ const LIST_ITEM_END = /<\/li\s*>/gi;
36
+ /** Start of a list item, replaced by a bullet marker. */
37
+ const LIST_ITEM_START = /<li\b[^>]*>/gi;
38
+ /**
39
+ * An ordered list, whose items are numbered in the order they appear. Matching
40
+ * closes at the first same-tag closing tag, so a nested ordered list numbers
41
+ * as part of its parent's sequence.
42
+ */
43
+ const ORDERED_LIST = /<ol\b[^>]*>[\s\S]*?<\/ol\s*>/gi;
44
+ /**
45
+ * An image, replaced by what it says. Alt text is the whole of an image in a text part,
46
+ * and often in the HTML one too: every major client blocks remote images until asked.
47
+ */
48
+ const IMAGE = /<img\b[^>]*\balt\s*=\s*("([^"]*)"|'([^']*)')[^>]*>/gi;
49
+ /** Boundaries that read as a paragraph break. */
50
+ const PARAGRAPH_BOUNDARY = /<\/?(p|div|h[1-6]|table|ul|ol|dl|blockquote|section|article|header|footer|hr|address|pre|figure)\b[^>]*>/gi;
51
+ /** End of a table cell, which separates cells on the same line with a space. */
52
+ const CELL_END = /<\/(td|th)\s*>/gi;
53
+ /** End of a table row or definition entry, which ends the line so the next row starts immediately below it. */
54
+ const ROW_END = /<\/(tr|dt|dd|caption)\s*>/gi;
55
+ /** Any remaining tag, dropped once its structural meaning has been applied. */
56
+ const TAG = /<[^>]+>/g;
57
+ /** A character reference in named, decimal, or hexadecimal form. */
58
+ const ENTITY = /&(#\d+|#x[0-9a-f]+|[a-z]+);/gi;
59
+ /** Named references worth decoding: the ones a renderer emits or copy commonly uses. */
60
+ const NAMED_ENTITIES = {
61
+ amp: "&",
62
+ apos: "'",
63
+ gt: ">",
64
+ hellip: "…",
65
+ lt: "<",
66
+ mdash: "—",
67
+ nbsp: " ",
68
+ ndash: "–",
69
+ quot: '"',
70
+ };
71
+ /** Runs of horizontal whitespace, including the no-break space entities decode to. */
72
+ const HORIZONTAL_WHITESPACE = /[ \t\r\f\v\u00a0]+/g;
73
+ /** Three or more newlines, collapsed so structure never turns into empty screens. */
74
+ const EXTRA_NEWLINES = /\n{3,}/g;
75
+ /** Removes markup from a fragment that has already had its structure applied. */
76
+ function stripTags(html) {
77
+ return html.replace(TAG, "");
78
+ }
79
+ /** Resolves character references to the characters they stand for, leaving unknown ones intact. */
80
+ function decodeEntities(text) {
81
+ return text.replace(ENTITY, (match, reference) => {
82
+ let name = reference.toLowerCase();
83
+ let code = name.startsWith("#x")
84
+ ? Number.parseInt(name.slice(2), 16)
85
+ : name.startsWith("#")
86
+ ? Number.parseInt(name.slice(1), 10)
87
+ : Number.NaN;
88
+ if (Number.isNaN(code))
89
+ return NAMED_ENTITIES[name] ?? match;
90
+ if (code < 1 || code > 0x10ffff)
91
+ return match;
92
+ return String.fromCodePoint(code);
93
+ });
94
+ }
95
+ /**
96
+ * Renders a link as text. The target is kept beside the label because a plain-text
97
+ * reader has no other way to reach it, and dropped when the label already is the
98
+ * target so the URL is not printed twice.
99
+ */
100
+ function formatLink(href, label) {
101
+ let target = href.trim();
102
+ let text = label.trim();
103
+ if (!target)
104
+ return text;
105
+ if (!text)
106
+ return target;
107
+ if (text === target || text === target.replace(/^mailto:/i, ""))
108
+ return text;
109
+ return `${text} (${target})`;
110
+ }
111
+ /**
112
+ * Converts rendered email HTML into its plain-text alternative, preserving link
113
+ * targets as `label (href)`, image alt text, and block structure as blank lines
114
+ * so the text part carries the same content the HTML conveys visually.
115
+ *
116
+ * @param html - Rendered HTML of an email body.
117
+ * @returns The plain-text alternative, trimmed and with runs of blank lines collapsed.
118
+ * @example htmlToText('<p>Hi <a href="https://x.dev">here</a></p>'); // "Hi here (https://x.dev)"
119
+ */
120
+ export function htmlToText(html) {
121
+ let text = html
122
+ .replace(COMMENT, "")
123
+ .replace(DOCTYPE, "")
124
+ .replace(HIDDEN_BLOCK, "")
125
+ .replace(SKIPPED_BLOCK, "")
126
+ .replace(SKIPPED_VOID, "")
127
+ .replace(DROPPED_BLOCK, "")
128
+ .replace(LINE_BREAK, "\n")
129
+ .replace(ANCHOR, (_match, _target, double, single, bare, label) => formatLink(double ?? single ?? bare ?? "", stripTags(label)))
130
+ .replace(IMAGE, (_match, _quoted, double, single) => {
131
+ return double ?? single ?? "";
132
+ })
133
+ .replace(ORDERED_LIST, (list) => {
134
+ let position = 0;
135
+ return list.replace(LIST_ITEM_START, () => `${(position += 1)}. `);
136
+ })
137
+ .replace(LIST_ITEM_END, "\n")
138
+ .replace(LIST_ITEM_START, "- ")
139
+ .replace(CELL_END, " ")
140
+ .replace(ROW_END, "\n")
141
+ .replace(PARAGRAPH_BOUNDARY, "\n\n");
142
+ return decodeEntities(stripTags(text))
143
+ .replace(HORIZONTAL_WHITESPACE, " ")
144
+ .split("\n")
145
+ .map((line) => line.trim())
146
+ .join("\n")
147
+ .replace(EXTRA_NEWLINES, "\n\n")
148
+ .trim();
149
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The mailer: it owns normalization (defaults, address coercion, plain-text
3
+ * derivation, validation) and hands the result to a transport. Delivery is a
4
+ * value, so `send()` reports failure as a `Result` and never throws, and
5
+ * `later()` gives fire-and-forget mail a defined lifetime instead of an
6
+ * unawaited promise.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { Result } from "@sdxc/result";
12
+ import type { Address, Email, Message, SentMessage, Transport } from "./types.js";
13
+ import { MailError } from "./errors.js";
14
+ /**
15
+ * Fields that replace what a `Message` or an `Email` already provides for a single
16
+ * send, such as redirecting an email to a forwarded address.
17
+ */
18
+ export type SendOptions = Partial<Message>;
19
+ /** Configuration every message a mailer sends inherits. */
20
+ export interface MailerOptions {
21
+ transport: Transport;
22
+ /** Sender identity for the app; a message may override it. */
23
+ from: Address;
24
+ /** Where replies go by default; a message or email may override it. */
25
+ replyTo?: Address | Address[];
26
+ /** Headers added to every message, with per-message headers winning. */
27
+ headers?: Record<string, string>;
28
+ }
29
+ /**
30
+ * Reports whether a value is an `Email` rather than a plain `Message`.
31
+ * Discrimination is structural: a callable `body` is the one member only an email
32
+ * has, so anything else is a message.
33
+ *
34
+ * @param value - The value handed to `send()` or `later()`.
35
+ * @returns `true` when the value carries a callable `body`.
36
+ */
37
+ export declare function isEmail(value: Message | Email): value is Email;
38
+ /**
39
+ * Sends mail through a transport, applying one app's sender identity to every
40
+ * message. The instance is request-scoped when `later()` is used, because the
41
+ * queue it fills belongs to whoever flushes it.
42
+ */
43
+ export declare class Mailer {
44
+ #private;
45
+ /**
46
+ * Creates a mailer.
47
+ *
48
+ * @param options - Transport and sender configuration; see {@link MailerOptions}.
49
+ */
50
+ constructor(options: MailerOptions);
51
+ /** Number of messages waiting for the next `flush()`. */
52
+ get pending(): number;
53
+ /**
54
+ * Normalizes and delivers a message, awaiting the outcome. Never throws: a
55
+ * render failure, an invalid message, a rejected delivery, and a transport
56
+ * that throws all arrive as a `MailError` failure for the caller to inspect.
57
+ *
58
+ * @param input - A plain message, or an email object that renders its own body.
59
+ * @param overrides - Fields that replace what the input provides for this send.
60
+ * @returns The provider's identifier on success, a `MailError` on failure.
61
+ * @example let result = await mailer.send(new TeamInviteEmail(invite));
62
+ */
63
+ send(input: Message | Email, overrides?: SendOptions): Promise<Result<SentMessage, MailError>>;
64
+ /**
65
+ * Queues a message to be sent once the caller flushes, which is how a send whose
66
+ * failure must not affect the response gets a defined lifetime. Nothing is
67
+ * rendered or validated yet; that happens at flush time.
68
+ *
69
+ * @param input - A plain message, or an email object that renders its own body.
70
+ * @param overrides - Fields that replace what the input provides for this send.
71
+ * @example ctx.email.later(new PasswordResetEmail(user));
72
+ */
73
+ later(input: Message | Email, overrides?: SendOptions): void;
74
+ /**
75
+ * Sends everything `later()` queued and empties the queue. Results come back in
76
+ * queue order for the caller to log; like `send()`, it never throws, so a failed
77
+ * deferred message cannot break whoever is flushing.
78
+ *
79
+ * @returns One result per queued message, in the order they were queued.
80
+ */
81
+ flush(): Promise<Result<SentMessage, MailError>[]>;
82
+ }
package/dist/mailer.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The mailer: it owns normalization (defaults, address coercion, plain-text
3
+ * derivation, validation) and hands the result to a transport. Delivery is a
4
+ * value, so `send()` reports failure as a `Result` and never throws, and
5
+ * `later()` gives fire-and-forget mail a defined lifetime instead of an
6
+ * unawaited promise.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { failure, isFailure, success, wrap } from "@sdxc/result";
12
+ import { MailError } from "./errors.js";
13
+ import { isValidAddress, toAddressList } from "./lib/address.js";
14
+ import { htmlToText } from "./lib/html-to-text.js";
15
+ import { render } from "./render.js";
16
+ /**
17
+ * Reports whether a value is an `Email` rather than a plain `Message`.
18
+ * Discrimination is structural: a callable `body` is the one member only an email
19
+ * has, so anything else is a message.
20
+ *
21
+ * @param value - The value handed to `send()` or `later()`.
22
+ * @returns `true` when the value carries a callable `body`.
23
+ */
24
+ export function isEmail(value) {
25
+ return typeof value.body === "function";
26
+ }
27
+ /**
28
+ * Chooses the plain-text part. An explicit one always wins, so a caller can
29
+ * replace a derived version that reads badly; otherwise it derives from the
30
+ * HTML actually sent, keeping the two parts from disagreeing on an override.
31
+ */
32
+ function derivePlainText(message, overrides) {
33
+ if (overrides?.text !== undefined)
34
+ return overrides.text;
35
+ if (overrides?.html !== undefined)
36
+ return htmlToText(overrides.html);
37
+ if (message.text !== undefined)
38
+ return message.text;
39
+ if (message.html !== undefined)
40
+ return htmlToText(message.html);
41
+ return undefined;
42
+ }
43
+ /**
44
+ * Builds a `Message-ID` inside the sender's domain, which is what receivers expect
45
+ * of a generated identifier. Callers that need a stable value set `messageId` on
46
+ * the message instead of reaching for a clock or id seam.
47
+ */
48
+ function generateMessageId(from) {
49
+ let domain = from.email.split("@").at(1) ?? "localhost";
50
+ return `<${crypto.randomUUID()}@${domain}>`;
51
+ }
52
+ /**
53
+ * Rejects messages no transport could deliver, so the failure names the mistake
54
+ * instead of surfacing as a provider error later: a missing sender or recipient,
55
+ * an address that cannot be routed, or a message with no body at all.
56
+ */
57
+ function validate(message) {
58
+ if (!message.from.email)
59
+ return failure(new MailError("A message needs a sender address."));
60
+ if (message.to.length === 0) {
61
+ return failure(new MailError("A message needs at least one recipient."));
62
+ }
63
+ let addresses = [message.from, ...message.to, ...message.cc, ...message.bcc, ...message.replyTo];
64
+ for (let address of addresses) {
65
+ if (isValidAddress(address))
66
+ continue;
67
+ return failure(new MailError(`"${address.email}" is not a valid email address.`));
68
+ }
69
+ if (!message.html && !message.text) {
70
+ return failure(new MailError("A message needs an HTML or a plain-text body."));
71
+ }
72
+ return success(message);
73
+ }
74
+ /**
75
+ * Sends mail through a transport, applying one app's sender identity to every
76
+ * message. The instance is request-scoped when `later()` is used, because the
77
+ * queue it fills belongs to whoever flushes it.
78
+ */
79
+ export class Mailer {
80
+ /** Configuration shared by every message this mailer sends. */
81
+ #options;
82
+ /** Messages queued by `later()`, drained by `flush()`. */
83
+ #queue = [];
84
+ /**
85
+ * Creates a mailer.
86
+ *
87
+ * @param options - Transport and sender configuration; see {@link MailerOptions}.
88
+ */
89
+ constructor(options) {
90
+ this.#options = options;
91
+ }
92
+ /** Number of messages waiting for the next `flush()`. */
93
+ get pending() {
94
+ return this.#queue.length;
95
+ }
96
+ /**
97
+ * Normalizes and delivers a message, awaiting the outcome. Never throws: a
98
+ * render failure, an invalid message, a rejected delivery, and a transport
99
+ * that throws all arrive as a `MailError` failure for the caller to inspect.
100
+ *
101
+ * @param input - A plain message, or an email object that renders its own body.
102
+ * @param overrides - Fields that replace what the input provides for this send.
103
+ * @returns The provider's identifier on success, a `MailError` on failure.
104
+ * @example let result = await mailer.send(new TeamInviteEmail(invite));
105
+ */
106
+ async send(input, overrides) {
107
+ let prepared = await this.#normalize(input, overrides);
108
+ if (isFailure(prepared))
109
+ return prepared;
110
+ let message = prepared.data;
111
+ let outcome = await wrap(() => this.#options.transport.send(message));
112
+ if (isFailure(outcome)) {
113
+ return failure(new MailError("The transport threw while sending the message.", {
114
+ cause: outcome.error,
115
+ }));
116
+ }
117
+ return outcome.data;
118
+ }
119
+ /**
120
+ * Queues a message to be sent once the caller flushes, which is how a send whose
121
+ * failure must not affect the response gets a defined lifetime. Nothing is
122
+ * rendered or validated yet; that happens at flush time.
123
+ *
124
+ * @param input - A plain message, or an email object that renders its own body.
125
+ * @param overrides - Fields that replace what the input provides for this send.
126
+ * @example ctx.email.later(new PasswordResetEmail(user));
127
+ */
128
+ later(input, overrides) {
129
+ this.#queue.push({ input, overrides });
130
+ }
131
+ /**
132
+ * Sends everything `later()` queued and empties the queue. Results come back in
133
+ * queue order for the caller to log; like `send()`, it never throws, so a failed
134
+ * deferred message cannot break whoever is flushing.
135
+ *
136
+ * @returns One result per queued message, in the order they were queued.
137
+ */
138
+ async flush() {
139
+ let queued = this.#queue.splice(0, this.#queue.length);
140
+ return await Promise.all(queued.map((entry) => this.send(entry.input, entry.overrides)));
141
+ }
142
+ /**
143
+ * Turns an email object into a plain message by reading its addressing and
144
+ * rendering its body to both parts. Plain messages pass through untouched.
145
+ */
146
+ async #toMessage(input) {
147
+ if (!isEmail(input))
148
+ return success(input);
149
+ let rendered = await wrap(() => render(input.body()));
150
+ if (isFailure(rendered)) {
151
+ return failure(new MailError("Failed to render the email body.", { cause: rendered.error }));
152
+ }
153
+ return success({
154
+ to: input.to,
155
+ subject: input.subject,
156
+ replyTo: input.replyTo,
157
+ headers: input.headers,
158
+ html: rendered.data.html,
159
+ text: rendered.data.text,
160
+ });
161
+ }
162
+ /**
163
+ * Produces the shape transports read: configured defaults filled in, address
164
+ * lists coerced, a plain-text part derived, and the source email recorded for
165
+ * type-based lookup. Validation runs last, so a failure describes the final message.
166
+ */
167
+ async #normalize(input, overrides) {
168
+ let converted = await this.#toMessage(input);
169
+ if (isFailure(converted))
170
+ return converted;
171
+ let message = { ...converted.data, ...overrides };
172
+ let from = message.from ?? this.#options.from;
173
+ return validate({
174
+ from,
175
+ to: toAddressList(message.to),
176
+ cc: toAddressList(message.cc),
177
+ bcc: toAddressList(message.bcc),
178
+ replyTo: toAddressList(message.replyTo ?? this.#options.replyTo),
179
+ subject: message.subject,
180
+ html: message.html,
181
+ text: derivePlainText(converted.data, overrides),
182
+ headers: { ...this.#options.headers, ...message.headers },
183
+ date: message.date ?? new Date(),
184
+ messageId: message.messageId ?? generateMessageId(from),
185
+ email: isEmail(input) ? input : undefined,
186
+ });
187
+ }
188
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Markdown as an email body, and the highlighted code block it renders fences with.
3
+ *
4
+ * A separate entry point keeps the highlighter out of mail bundles that carry no markdown.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Token } from "@sdxc/highlight";
10
+ import type { Markdown as Ast } from "@sdxc/markdown";
11
+ import type { Handle } from "remix/ui";
12
+ export declare namespace CodeBlock {
13
+ /** Props accepted by {@link CodeBlock}. */
14
+ interface Props {
15
+ code: string;
16
+ /** Language to highlight as; an unknown one still renders, left unpainted. */
17
+ language?: string;
18
+ /** Already-highlighted runs, as a painted document carries them. */
19
+ tokens?: Token[];
20
+ }
21
+ }
22
+ /**
23
+ * A fenced block of code, highlighted, inside a single-cell table.
24
+ *
25
+ * The table keeps the block's background solid in Outlook, which paints a `<pre>`
26
+ * no wider than its text, so long lines wrap since an inbox offers no scrollbar.
27
+ *
28
+ * @example <CodeBlock language="bash" code="bun run deploy" />
29
+ */
30
+ export declare function CodeBlock(handle: Handle<CodeBlock.Props>): () => import("remix/ui").RemixElement;
31
+ export declare namespace Markdown {
32
+ /** Props accepted by {@link Markdown}. */
33
+ interface Props {
34
+ /** The document to render; its code blocks arrive painted when the caller painted them. */
35
+ document: Ast.Document;
36
+ }
37
+ }
38
+ /**
39
+ * Renders a parsed document as an email body, using the layout kit for every element.
40
+ *
41
+ * The document arrives parsed, so a caller holding one pays for no parser here, and the
42
+ * same tree an inbox receives is the tree a page renders.
43
+ *
44
+ * @example <Markdown document={notes} />
45
+ */
46
+ export declare function Markdown(handle: Handle<Markdown.Props>): () => import("remix/ui").RemixElement;
@@ -0,0 +1,182 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "remix/ui/jsx-runtime";
2
+ import { tokenize } from "@sdxc/highlight";
3
+ import { CODE_COLOR, CodeInline, Heading, Hr, Img, Link, MONO_FAMILY, Text } from "./components.js";
4
+ /**
5
+ * The colour of each token type, and the class that recolours it in dark mode.
6
+ *
7
+ * Six colours for twenty types, because an inbox reads at a glance and every
8
+ * colour spent here is another rule the dark-mode stylesheet has to carry.
9
+ */
10
+ const TOKENS = {
11
+ comment: { color: "#6a737d", class: "mail-tok-comment" },
12
+ keyword: { color: "#d73a49", class: "mail-tok-keyword" },
13
+ operator: { color: "#24292e", class: "mail-tok-punctuation" },
14
+ punctuation: { color: "#24292e", class: "mail-tok-punctuation" },
15
+ string: { color: "#032f62", class: "mail-tok-string" },
16
+ regex: { color: "#032f62", class: "mail-tok-string" },
17
+ "attr-value": { color: "#032f62", class: "mail-tok-string" },
18
+ builtin: { color: "#032f62", class: "mail-tok-string" },
19
+ number: { color: "#005cc5", class: "mail-tok-number" },
20
+ boolean: { color: "#005cc5", class: "mail-tok-number" },
21
+ constant: { color: "#005cc5", class: "mail-tok-number" },
22
+ property: { color: "#005cc5", class: "mail-tok-number" },
23
+ function: { color: "#6f42c1", class: "mail-tok-function" },
24
+ "class-name": { color: "#6f42c1", class: "mail-tok-function" },
25
+ tag: { color: "#6f42c1", class: "mail-tok-function" },
26
+ "attr-name": { color: "#6f42c1", class: "mail-tok-function" },
27
+ variable: { color: "#6f42c1", class: "mail-tok-function" },
28
+ inserted: { color: "#22863a", class: "mail-tok-inserted" },
29
+ deleted: { color: "#b31d28", class: "mail-tok-deleted" },
30
+ plain: undefined,
31
+ };
32
+ /** Hairline colour around a data table's cells, matching the kit's own rules. */
33
+ const TABLE_BORDER_COLOR = "#e4e4e7";
34
+ /** One highlighted run, as a coloured span or as bare text where nothing paints it. */
35
+ function highlight(tokens) {
36
+ return tokens.map((token, index) => {
37
+ let painted = TOKENS[token.type];
38
+ if (!painted)
39
+ return token.value;
40
+ return (_jsx("span", { class: painted.class, style: `color:${painted.color};`, children: token.value }, index));
41
+ });
42
+ }
43
+ /**
44
+ * A fenced block of code, highlighted, inside a single-cell table.
45
+ *
46
+ * The table keeps the block's background solid in Outlook, which paints a `<pre>`
47
+ * no wider than its text, so long lines wrap since an inbox offers no scrollbar.
48
+ *
49
+ * @example <CodeBlock language="bash" code="bun run deploy" />
50
+ */
51
+ export function CodeBlock(handle) {
52
+ return () => {
53
+ let { code, language, tokens } = handle.props;
54
+ let content = highlight(tokens ?? tokenize(code, language ?? "plain"));
55
+ return (_jsx("table", { role: "presentation", width: "100%", cellPadding: "0", cellSpacing: "0", style: "width:100%;margin:0 0 16px;border-collapse:collapse;", children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { class: "mail-code", style: `padding:12px 16px;border-radius:6px;background-color:${CODE_COLOR};`, children: _jsx("pre", { style: `margin:0;padding:0;font-family:${MONO_FAMILY};font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;`, children: content }) }) }) }) }));
56
+ };
57
+ }
58
+ /** An image's alternative text is inline content in the source and an attribute in the markup. */
59
+ function textOf(nodes) {
60
+ return nodes.map(textOfNode).join("");
61
+ }
62
+ /** Reads the prose out of one node, a break becoming the space it stands for. */
63
+ function textOfNode(node) {
64
+ switch (node.type) {
65
+ case "text":
66
+ case "inlineCode":
67
+ case "inlineHtml":
68
+ case "html":
69
+ return node.value;
70
+ case "code":
71
+ return node.content;
72
+ case "softBreak":
73
+ case "hardBreak":
74
+ return " ";
75
+ case "variable":
76
+ return `{% $${node.name} %}`;
77
+ case "footnoteReference":
78
+ return `[${node.identifier}]`;
79
+ case "thematicBreak":
80
+ return "";
81
+ default:
82
+ return textOf([...node.children]);
83
+ }
84
+ }
85
+ /** Renders a run of nodes in order, which is what every parent does with its children. */
86
+ function children(nodes) {
87
+ return nodes.map(convert);
88
+ }
89
+ /** Each cell takes the alignment of the column it sits in, which the table carries once. */
90
+ function convertRow(node, key, align) {
91
+ return (_jsx("tr", { children: node.children.map((cell, index) => convertCell(cell, index, node.header, align[index] ?? null)) }, key));
92
+ }
93
+ /** A header row's cells are the column headings, so they carry the weight a reader scans for. */
94
+ function convertCell(node, key, header, align) {
95
+ let style = `padding:8px 12px;border:1px solid ${TABLE_BORDER_COLOR};font-family:inherit;font-size:14px;line-height:1.4;text-align:${align ?? "left"};vertical-align:top;`;
96
+ if (header) {
97
+ return (_jsx("th", { class: "mail-rule", style: `${style}font-weight:600;`, children: children(node.children) }, key));
98
+ }
99
+ return (_jsx("td", { class: "mail-rule", style: style, children: children(node.children) }, key));
100
+ }
101
+ /**
102
+ * Turns one node into email components.
103
+ *
104
+ * Content an inbox cannot lay out still renders, in whatever form reads: an
105
+ * ordered list becomes a real `<ol>`, so its plain-text conversion numbers each item.
106
+ */
107
+ function convert(node, key) {
108
+ switch (node.type) {
109
+ case "heading": {
110
+ if (node.level === 1)
111
+ return _jsx(Heading, { children: children(node.children) }, key);
112
+ if (node.level === 2) {
113
+ return (_jsx(Heading, { level: 2, children: children(node.children) }, key));
114
+ }
115
+ return (_jsx(Heading, { level: 3, children: children(node.children) }, key));
116
+ }
117
+ case "paragraph":
118
+ return _jsx(Text, { children: children(node.children) }, key);
119
+ case "code":
120
+ return (_jsx(CodeBlock, { code: node.content, language: node.language, tokens: node.tokens }, key));
121
+ case "list": {
122
+ if (node.ordered) {
123
+ return (_jsx("ol", { start: node.start, style: "margin:0 0 16px;padding:0 0 0 20px;list-style-type:decimal;", children: children(node.children) }, key));
124
+ }
125
+ return (_jsx("ul", { style: "margin:0 0 16px;padding:0 0 0 20px;list-style-type:disc;", children: children(node.children) }, key));
126
+ }
127
+ case "listItem":
128
+ return (_jsx("li", { style: "margin:0 0 6px;font-family:inherit;line-height:1.6;", children: node.children.map((child, index) => child.type === "paragraph" ? children(child.children) : convert(child, index)) }, key));
129
+ case "blockquote":
130
+ return (_jsx("blockquote", { class: "mail-rule", style: "margin:0 0 16px;padding:0 0 0 16px;border-left:3px solid #e4e4e7;", children: children(node.children) }, key));
131
+ case "alert":
132
+ return (_jsxs("blockquote", { class: "mail-rule", style: "margin:0 0 16px;padding:0 0 0 16px;border-left:3px solid #e4e4e7;", children: [_jsx(Text, { size: 13, muted: true, children: node.kind.toUpperCase() }), children(node.children)] }, key));
133
+ case "table":
134
+ return (_jsx("table", { width: "100%", cellPadding: "0", cellSpacing: "0", style: "width:100%;margin:0 0 16px;border-collapse:collapse;", children: _jsx("tbody", { children: node.children.map((row, index) => convertRow(row, index, node.align)) }) }, key));
135
+ case "tableRow":
136
+ return convertRow(node, key, []);
137
+ case "tableCell":
138
+ return convertCell(node, key, false, null);
139
+ case "thematicBreak":
140
+ return _jsx(Hr, {}, key);
141
+ case "html":
142
+ case "inlineHtml":
143
+ return node.value;
144
+ case "footnoteDefinition":
145
+ return (_jsxs("div", { style: "margin:0 0 16px;font-family:inherit;font-size:14px;line-height:1.6;", children: [_jsx("strong", { style: "font-weight:600;", children: `[${node.identifier}] ` }), children(node.children)] }, key));
146
+ case "tag":
147
+ return children([...node.children]);
148
+ case "text":
149
+ return node.value;
150
+ case "emphasis":
151
+ return (_jsx("em", { style: "font-style:italic;", children: children(node.children) }, key));
152
+ case "strong":
153
+ return (_jsx("strong", { style: "font-weight:600;", children: children(node.children) }, key));
154
+ case "strikethrough":
155
+ return (_jsx("s", { style: "text-decoration:line-through;", children: children(node.children) }, key));
156
+ case "inlineCode":
157
+ return _jsx(CodeInline, { children: node.value }, key);
158
+ case "link":
159
+ return (_jsx(Link, { href: node.href, children: children(node.children) }, key));
160
+ case "image":
161
+ return _jsx(Img, { src: node.src, alt: textOf(node.children), gap: 16 }, key);
162
+ case "softBreak":
163
+ return "\n";
164
+ case "hardBreak":
165
+ return _jsx("br", {}, key);
166
+ case "footnoteReference":
167
+ return _jsx("sup", { style: "font-size:0.75em;line-height:1;", children: `[${node.identifier}]` }, key);
168
+ case "variable":
169
+ return `{% $${node.name} %}`;
170
+ }
171
+ }
172
+ /**
173
+ * Renders a parsed document as an email body, using the layout kit for every element.
174
+ *
175
+ * The document arrives parsed, so a caller holding one pays for no parser here, and the
176
+ * same tree an inbox receives is the tree a page renders.
177
+ *
178
+ * @example <Markdown document={notes} />
179
+ */
180
+ export function Markdown(handle) {
181
+ return () => _jsx(_Fragment, { children: children(handle.props.document.children) });
182
+ }