821 lines
26 KiB
TypeScript
821 lines
26 KiB
TypeScript
/**
|
||
* Every email the site sends shares one layout: the site's emoji and name,
|
||
* then a card holding the content, then a footer. The templates beside this
|
||
* module (welcome, password reset, digest) build their content with an
|
||
* {@link EmailKit}, which draws each block in a style from `styles.ts`, and
|
||
* `email-service.ts` sends what they return.
|
||
*
|
||
* Mail clients drop stylesheets, so every block carries inline styles. A
|
||
* reader on the "auto" scheme gets the light palette inline and the dark one
|
||
* in a `prefers-color-scheme` block that Apple's mail apps honour; elsewhere
|
||
* the light one shows.
|
||
*
|
||
* @module
|
||
*/
|
||
|
||
import { Marked, Parser, type Token, type Tokens } from "marked";
|
||
|
||
import {
|
||
DEFAULT_STYLE,
|
||
EMAIL_LOCALE,
|
||
OG_SITE_NAME,
|
||
SITE_EMOJI,
|
||
} from "../config.ts";
|
||
import {
|
||
type ColorScheme,
|
||
DEFAULT_COLOR_SCHEME,
|
||
type StyleName,
|
||
} from "../../shared/theme.ts";
|
||
import type { UserSettings } from "../../shared/settings.ts";
|
||
import { EMAIL_STYLES, type EmailPalette, type EmailStyle } from "./styles.ts";
|
||
|
||
/**
|
||
* Returned by each template to its caller, which hands it to `sendEmail`
|
||
* with a recipient.
|
||
*/
|
||
export interface RenderedEmail {
|
||
subject: string;
|
||
html: string;
|
||
/**
|
||
* The plain-text part, for clients that show no HTML. It is Markdown, so it
|
||
* reads as text and renders as the email's outline.
|
||
*/
|
||
text: string;
|
||
}
|
||
|
||
/**
|
||
* Passed to {@link EmailKit}, made by {@link lookOf}: the instance's style for
|
||
* the emails that aren't a user's own, their settings for a digest.
|
||
*/
|
||
export interface EmailLook {
|
||
style: StyleName;
|
||
scheme: ColorScheme;
|
||
}
|
||
|
||
/**
|
||
* Returns the look of a reader's settings. An unset style is the instance's,
|
||
* as on the site, and an unset scheme the system's; with no settings at all,
|
||
* as for the welcome and reset emails, both are.
|
||
*/
|
||
export function lookOf(
|
||
settings: Pick<UserSettings, "style" | "colorScheme"> | null = null,
|
||
): EmailLook {
|
||
return {
|
||
style: settings?.style ?? DEFAULT_STYLE,
|
||
scheme: settings?.colorScheme ?? DEFAULT_COLOR_SCHEME,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Names a color of an {@link EmailPalette}, as {@link EmailKit.paint} takes
|
||
* it, so that the dark block can recolor the element by the same name.
|
||
*/
|
||
export type PaletteRole = Exclude<keyof EmailPalette, "gradient">;
|
||
|
||
const ROLES: PaletteRole[] = [
|
||
"outer",
|
||
"card",
|
||
"text",
|
||
"sec",
|
||
"muted",
|
||
"accent",
|
||
"link",
|
||
"onAccent",
|
||
"rule",
|
||
"soft",
|
||
"quote",
|
||
"date",
|
||
"brand",
|
||
"border",
|
||
];
|
||
|
||
/**
|
||
* Escapes `value` for HTML text and attributes. Everything users wrote (names,
|
||
* titles, quotes) goes through it, since none of it may become markup.
|
||
*/
|
||
export function esc(value: string): string {
|
||
return value.replace(
|
||
/[&<>"']/g,
|
||
(c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
||
c
|
||
]!,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Escapes `value` for Markdown, so that user text in a plain-text part (a
|
||
* title with `*` or `[`, say) stays text.
|
||
*/
|
||
export function escMd(value: string): string {
|
||
return value.replace(/[\\`*_[\]<>#!|~]/g, "\\$&");
|
||
}
|
||
|
||
/** Returns a Markdown link to `url` labelled with `text`, escaped. */
|
||
export function mdLink(text: string, url: string): string {
|
||
const safeUrl = url.replace(
|
||
/[()\s]/g,
|
||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
|
||
);
|
||
return `[${escMd(text)}](${safeUrl})`;
|
||
}
|
||
|
||
const gradient = (colors: string[]) =>
|
||
`linear-gradient(90deg,${colors.join(",")})`;
|
||
|
||
/**
|
||
* Draws an email in one {@link EmailLook}. A template writes the email as
|
||
* Markdown and hands it to {@link EmailKit.render}, which draws each block in
|
||
* the style. The block methods return HTML: text passed to them must already
|
||
* be escaped with {@link esc}.
|
||
*/
|
||
export class EmailKit {
|
||
/** The style being drawn. */
|
||
readonly style: EmailStyle;
|
||
/** The palette inlined into the HTML. */
|
||
readonly palette: EmailPalette;
|
||
/** The palette that replaces it in dark mode, on the "auto" scheme only. */
|
||
readonly darkPalette: EmailPalette | null;
|
||
/** The font stack, for templates that set their own blocks. */
|
||
readonly font: string;
|
||
|
||
constructor(look: EmailLook) {
|
||
this.style = EMAIL_STYLES[look.style];
|
||
this.palette = look.scheme === "dark" ? this.style.dark : this.style.light;
|
||
this.darkPalette = look.scheme === DEFAULT_COLOR_SCHEME
|
||
? this.style.dark
|
||
: null;
|
||
this.font = this.style.font;
|
||
}
|
||
|
||
/** Returns `px`, scaled for the style, as a CSS length. */
|
||
fs(px: number): string {
|
||
return `${Math.round(px * this.style.scale * 2) / 2}px`;
|
||
}
|
||
|
||
/**
|
||
* Returns the `class` and `style` attributes of an element colored by
|
||
* roles: `fg` for its text, `bg` for its fill, `bd` for its border. The
|
||
* classes let the dark block recolor it; `css` is the rest of its style.
|
||
*/
|
||
paint(
|
||
roles: {
|
||
fg?: PaletteRole;
|
||
bg?: PaletteRole;
|
||
bd?: PaletteRole;
|
||
extra?: string[];
|
||
},
|
||
css = "",
|
||
): string {
|
||
const p = this.palette;
|
||
const classes: string[] = [...(roles.extra ?? [])];
|
||
let style = "";
|
||
if (roles.fg) {
|
||
classes.push(`f-${roles.fg}`);
|
||
style += `color:${p[roles.fg] ?? p.text};`;
|
||
}
|
||
if (roles.bg) {
|
||
classes.push(`b-${roles.bg}`);
|
||
style += `background-color:${p[roles.bg] ?? p.card};`;
|
||
}
|
||
if (roles.bd) {
|
||
classes.push(`d-${roles.bd}`);
|
||
style += `border-color:${p[roles.bd] ?? p.rule};`;
|
||
}
|
||
const cls = classes.length ? ` class="${classes.join(" ")}"` : "";
|
||
// The colors come last: a shorthand such as `border-top` in `css` would
|
||
// otherwise reset them.
|
||
return `${cls} style="${css}${style}"`;
|
||
}
|
||
|
||
/** Returns a link to `href` around `html`, in the link color. */
|
||
link(html: string, href: string, strong = false): string {
|
||
const s = this.style;
|
||
return `<a href="${esc(href)}"${
|
||
this.paint(
|
||
{ fg: "link" },
|
||
`text-decoration:${s.linkUnderline ? "underline" : "none"};${
|
||
strong ? `font-weight:${s.heavy};` : ""
|
||
}`,
|
||
)
|
||
}>${html}</a>`;
|
||
}
|
||
|
||
/** Returns the email's heading, in the style's title type. */
|
||
heading(html: string, marginBottom = 24): string {
|
||
const t = this.style.title;
|
||
return `<h1${
|
||
this.paint(
|
||
{ fg: t.accent ? "accent" : "text" },
|
||
`font-family:${this.font};font-size:${
|
||
this.fs(t.size)
|
||
};line-height:1.15;font-weight:${t.weight};${
|
||
t.upper ? "text-transform:uppercase;" : ""
|
||
}${
|
||
t.spacing ? `letter-spacing:${t.spacing};` : ""
|
||
}margin:0 0 ${marginBottom}px;`,
|
||
)
|
||
}>${html}</h1>`;
|
||
}
|
||
|
||
/** Returns a paragraph. `css` adds to or overrides its style. */
|
||
para(html: string, css = ""): string {
|
||
return `<p${
|
||
this.paint(
|
||
{ fg: "text" },
|
||
`font-family:${this.font};font-size:${
|
||
this.fs(15)
|
||
};line-height:1.6;margin:0 0 16px;${css}`,
|
||
)
|
||
}>${html}</p>`;
|
||
}
|
||
|
||
/** Returns a smaller paragraph in the muted color. */
|
||
note(html: string, css = ""): string {
|
||
return `<p${
|
||
this.paint(
|
||
{ fg: "muted" },
|
||
`font-family:${this.font};font-size:${
|
||
this.fs(13.5)
|
||
};line-height:1.6;margin:0 0 16px;${css}`,
|
||
)
|
||
}>${html}</p>`;
|
||
}
|
||
|
||
/** Returns a button linking to `href`. */
|
||
button(label: string, href: string): string {
|
||
const s = this.style;
|
||
const frame = s.frame === "hard"
|
||
? `border:2px solid ${this.palette.text};box-shadow:2px 2px 0 ${this.palette.text};`
|
||
: "";
|
||
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:6px 0 22px;"><tr><td${
|
||
this.paint(
|
||
{
|
||
bg: "accent",
|
||
bd: s.frame === "hard" ? "text" : undefined,
|
||
extra: s.frame === "hard" ? ["sh-btn"] : [],
|
||
},
|
||
`border-radius:${s.radius.btn}px;${frame}`,
|
||
)
|
||
}><a href="${esc(href)}"${
|
||
this.paint(
|
||
{ fg: "onAccent" },
|
||
`display:inline-block;padding:13px 22px;font-family:${this.font};font-size:${
|
||
this.fs(13.5)
|
||
};font-weight:${
|
||
Math.max(600, s.heavy)
|
||
};letter-spacing:0.08em;text-transform:uppercase;line-height:1;text-decoration:none;`,
|
||
)
|
||
}>${label}</a></td></tr></table>`;
|
||
}
|
||
|
||
/** Returns a small uppercase pill, as on one's own dump in a list. */
|
||
pill(label: string): string {
|
||
return `<span${
|
||
this.paint(
|
||
{ fg: "sec", bg: "soft" },
|
||
`display:inline-block;margin-left:6px;padding:1px 7px;border-radius:${this.style.radius.pill}px;font-size:${
|
||
this.fs(11)
|
||
};font-weight:600;letter-spacing:0.08em;text-transform:uppercase;vertical-align:2px;`,
|
||
)
|
||
}>${label}</span>`;
|
||
}
|
||
|
||
/** Returns an image block, rounded as the style rounds images. */
|
||
image(
|
||
src: string,
|
||
alt: string,
|
||
width: number,
|
||
height: number,
|
||
href?: string,
|
||
): string {
|
||
const img = `<img src="${
|
||
esc(src)
|
||
}" width="${width}" height="${height}" alt="${
|
||
esc(alt)
|
||
}" style="display:block;max-width:100%;height:auto;border:0;border-radius:${
|
||
Math.min(this.style.radius.img, width / 8)
|
||
}px;">`;
|
||
return href ? `<a href="${esc(href)}">${img}</a>` : img;
|
||
}
|
||
|
||
/** Returns a small bold heading inside a section. */
|
||
subhead(html: string): string {
|
||
return `<div${
|
||
this.paint(
|
||
{ fg: "text" },
|
||
`font-family:${this.font};font-size:${this.fs(14)};font-weight:${
|
||
Math.max(600, this.style.heavy)
|
||
};margin:0 0 8px;`,
|
||
)
|
||
}>${html}</div>`;
|
||
}
|
||
|
||
/**
|
||
* Returns a section of the card: a divider in the style's rule (none before
|
||
* the first), an uppercase title in the accent color, and `html`.
|
||
*/
|
||
section(title: string, html: string, first = false): string {
|
||
const s = this.style;
|
||
let divider = "";
|
||
let border = "";
|
||
if (!first) {
|
||
const stops = this.palette.gradient;
|
||
if (s.rule === "gradient" && stops) {
|
||
divider =
|
||
`<div class="grad" style="height:3px;line-height:3px;font-size:0;background-color:${
|
||
stops[0]
|
||
};background-image:${gradient(stops)};margin:0 0 26px;"> </div>`;
|
||
} else {
|
||
border = `border-top:${
|
||
s.rule === "thick" ? 2 : 1
|
||
}px solid;padding-top:26px;`;
|
||
}
|
||
}
|
||
return `<div${
|
||
this.paint(
|
||
{ bd: border ? (s.rule === "thick" ? "text" : "rule") : undefined },
|
||
`${border}padding-bottom:26px;`,
|
||
)
|
||
}>${divider}<div${
|
||
this.paint(
|
||
{ fg: "accent" },
|
||
`font-family:${this.font};font-size:${this.fs(12)};font-weight:${
|
||
Math.max(600, s.heavy)
|
||
};letter-spacing:0.12em;text-transform:uppercase;margin:0 0 16px;`,
|
||
)
|
||
}>${title}</div>${html}</div>`;
|
||
}
|
||
|
||
/**
|
||
* Returns the email written as `markdown`: the Markdown itself is the
|
||
* plain-text part, and its blocks, drawn in this kit's style, are the HTML.
|
||
* Each template writes its email once this way. The blocks map as follows.
|
||
*
|
||
* - `# Title` is the heading; an `*emphasised*` paragraph above it, the
|
||
* eyebrow. Elsewhere an emphasised paragraph is a note.
|
||
* - `## Section` starts a section, with its divider; `### Name` a subhead.
|
||
* - A link alone in a paragraph is a button, an image alone a wide image,
|
||
* and a fenced block a box for a raw link.
|
||
* - A list item whose text starts with an image, then a link, is a row
|
||
* with that thumbnail and the link as its title; what follows is its
|
||
* detail, and an emphasised word right after the link a pill. Other items
|
||
* are sentences, where emphasis marks the date. A numbered list ranks its
|
||
* rows, and a quote under an item is set as one.
|
||
* - A bold number is a figure.
|
||
* - What follows the last `---` is the footer.
|
||
*/
|
||
render(
|
||
{ subject, preheader, markdown }: {
|
||
subject: string;
|
||
preheader: string;
|
||
markdown: string;
|
||
},
|
||
): RenderedEmail {
|
||
const cut = markdown.lastIndexOf("\n---\n");
|
||
const body = cut === -1 ? markdown : markdown.slice(0, cut);
|
||
const foot = cut === -1 ? "" : markdown.slice(cut + 5);
|
||
return {
|
||
subject,
|
||
html: this.document({
|
||
subject,
|
||
preheader,
|
||
body: this.blocks(this.marked.lexer(body)),
|
||
footer: this.marked.lexer(foot)
|
||
.filter((t): t is Tokens.Paragraph => t.type === "paragraph")
|
||
.map((t) => this.inline(t.tokens, "footer")).join("<br>"),
|
||
}),
|
||
text: markdown,
|
||
};
|
||
}
|
||
|
||
/** How {@link inline} draws emphasis and links, by where they sit. */
|
||
private mode: "text" | "footer" | "row" | "sentence" = "text";
|
||
|
||
/** Draws Markdown's inline tokens with the kit's colors. */
|
||
private readonly marked: Marked = (() => {
|
||
// deno-lint-ignore no-this-alias
|
||
const kit = this;
|
||
return new Marked({
|
||
renderer: {
|
||
link({ href, tokens }) {
|
||
const html = this.parser.parseInline(tokens);
|
||
return kit.mode === "footer"
|
||
? `<a href="${esc(href)}"${
|
||
kit.paint({ fg: "muted" }, "text-decoration:underline;")
|
||
}>${html}</a>`
|
||
: kit.link(html, href);
|
||
},
|
||
strong({ tokens }) {
|
||
const html = this.parser.parseInline(tokens);
|
||
const figure = /^[\d\s., ]+$/.test(html);
|
||
return `<b${
|
||
kit.paint(
|
||
{ fg: "text" },
|
||
figure
|
||
? `font-size:${kit.fs(22)};font-weight:${kit.style.heavy};`
|
||
: `font-weight:${Math.max(600, kit.style.heavy)};`,
|
||
)
|
||
}>${html}</b>`;
|
||
},
|
||
em({ tokens }) {
|
||
const html = this.parser.parseInline(tokens);
|
||
if (kit.mode === "row") return kit.pill(html);
|
||
if (kit.mode === "sentence") {
|
||
return `<span${
|
||
kit.paint(
|
||
{ fg: kit.palette.date ? "date" : "muted" },
|
||
`font-size:${kit.fs(12.5)};white-space:nowrap;${
|
||
kit.palette.date ? "font-style:italic;" : ""
|
||
}`,
|
||
)
|
||
}>${html}</span>`;
|
||
}
|
||
return `<i>${html}</i>`;
|
||
},
|
||
codespan({ text }) {
|
||
return `<code style="font-family:Menlo,Consolas,'Courier New',monospace;">${text}</code>`;
|
||
},
|
||
image({ href, text }) {
|
||
return kit.image(href, text, 104, 78);
|
||
},
|
||
},
|
||
});
|
||
})();
|
||
|
||
private inline(tokens: Token[], mode: EmailKit["mode"]): string {
|
||
this.mode = mode;
|
||
try {
|
||
return Parser.parseInline(tokens, this.marked.defaults);
|
||
} finally {
|
||
this.mode = "text";
|
||
}
|
||
}
|
||
|
||
/** Draws the body's blocks, gathering each `##` section with its content. */
|
||
private blocks(tokens: Token[]): string {
|
||
const out: string[] = [];
|
||
let section: { title: string; parts: string[] } | null = null;
|
||
let first = true;
|
||
let headed = false;
|
||
const push = (html: string) =>
|
||
section ? section.parts.push(html) : out.push(html);
|
||
const close = () => {
|
||
if (!section) return;
|
||
out.push(this.section(section.title, section.parts.join(""), first));
|
||
first = false;
|
||
section = null;
|
||
};
|
||
for (const token of tokens) {
|
||
switch (token.type) {
|
||
case "heading": {
|
||
const html = this.inline(token.tokens ?? [], "text");
|
||
if (token.depth === 1) {
|
||
headed = true;
|
||
push(this.heading(html));
|
||
} else if (token.depth === 2) {
|
||
close();
|
||
section = { title: html, parts: [] };
|
||
} else {
|
||
push(this.subhead(html));
|
||
}
|
||
break;
|
||
}
|
||
case "paragraph": {
|
||
const kids = (token.tokens ?? []).filter((t) =>
|
||
!(t.type === "text" && !t.raw.trim())
|
||
);
|
||
const only = kids.length === 1 ? kids[0] : null;
|
||
if (only?.type === "link") {
|
||
push(
|
||
this.button(this.inline(only.tokens ?? [], "text"), only.href),
|
||
);
|
||
} else if (only?.type === "image") {
|
||
push(
|
||
`${
|
||
this.image(only.href, only.text, 528, 264)
|
||
}<div style="height:14px;line-height:14px;font-size:0;"> </div>`,
|
||
);
|
||
} else if (only?.type === "em") {
|
||
const html = this.inline(only.tokens ?? [], "text");
|
||
push(headed ? this.note(html) : this.eyebrow(html));
|
||
} else {
|
||
push(this.para(this.inline(token.tokens ?? [], "text")));
|
||
}
|
||
break;
|
||
}
|
||
case "list":
|
||
push(this.list(token as Tokens.List));
|
||
break;
|
||
case "blockquote":
|
||
push(this.quote(this.inline(
|
||
(token.tokens ?? []).flatMap((t) =>
|
||
"tokens" in t ? t.tokens ?? [] : []
|
||
),
|
||
"text",
|
||
)));
|
||
break;
|
||
case "code":
|
||
push(this.linkBox(esc(token.text)));
|
||
break;
|
||
case "html":
|
||
push(token.text);
|
||
break;
|
||
}
|
||
}
|
||
close();
|
||
return out.join("");
|
||
}
|
||
|
||
/** Draws a list as rows, ranked when it is numbered. */
|
||
private list(list: Tokens.List): string {
|
||
const start = typeof list.start === "number" ? list.start : 1;
|
||
return list.items.map((item, i) => {
|
||
const text = item.tokens.find((t) =>
|
||
t.type === "text" || t.type === "paragraph"
|
||
) as Tokens.Text | Tokens.Paragraph | undefined;
|
||
const quote = item.tokens.find((t) => t.type === "blockquote") as
|
||
| Tokens.Blockquote
|
||
| undefined;
|
||
let rest: Token[] = [...(text?.tokens ?? [])];
|
||
const skipSpace = () => {
|
||
while (rest[0]?.type === "text" && !rest[0].raw.trim()) {
|
||
rest = rest.slice(1);
|
||
}
|
||
};
|
||
skipSpace();
|
||
const image = rest[0]?.type === "image"
|
||
? rest.shift() as Tokens.Image
|
||
: null;
|
||
skipSpace();
|
||
const link = rest[0]?.type === "link"
|
||
? rest.shift() as Tokens.Link
|
||
: null;
|
||
|
||
let content: string;
|
||
if (link) {
|
||
const pills: Token[] = [];
|
||
skipSpace();
|
||
while (rest[0]?.type === "em") {
|
||
pills.push(rest.shift()!);
|
||
skipSpace();
|
||
}
|
||
// The detail follows the title after a " · ".
|
||
if (rest[0]?.type === "text") {
|
||
const t = rest[0] as Tokens.Text;
|
||
const trimmed = t.text.replace(/^\s*·\s*/, "");
|
||
rest[0] = { ...t, text: trimmed, raw: trimmed, tokens: undefined };
|
||
}
|
||
content = `<a href="${esc(link.href)}"${
|
||
this.paint(
|
||
{ fg: "text" },
|
||
`font-size:${
|
||
this.fs(15)
|
||
};line-height:1.35;font-weight:${this.style.heavy};text-decoration:none;`,
|
||
)
|
||
}>${this.inline(link.tokens, "text")}</a>${this.inline(pills, "row")}${
|
||
rest.length
|
||
? `<div${
|
||
this.paint(
|
||
{ fg: "muted" },
|
||
`font-size:${this.fs(13)};line-height:1.5;margin-top:3px;`,
|
||
)
|
||
}>${this.inline(rest, "text")}</div>`
|
||
: ""
|
||
}`;
|
||
} else {
|
||
content = `<div${
|
||
this.paint(
|
||
{ fg: "sec" },
|
||
`font-size:${this.fs(14.5)};line-height:1.5;`,
|
||
)
|
||
}>${this.inline(rest, "sentence")}</div>`;
|
||
}
|
||
if (quote) {
|
||
content += this.quote(this.inline(
|
||
quote.tokens.flatMap((t) => "tokens" in t ? t.tokens ?? [] : []),
|
||
"text",
|
||
));
|
||
}
|
||
|
||
const size = list.ordered ? [64, 48] : [104, 78];
|
||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 ${
|
||
link || image ? 16 : 10
|
||
}px;"><tr>${
|
||
list.ordered
|
||
? `<td width="30" valign="top"${
|
||
this.paint(
|
||
{ fg: "accent" },
|
||
`font-family:${this.font};font-size:${this.fs(20)};font-weight:${
|
||
Math.max(600, this.style.heavy)
|
||
};line-height:1.2;`,
|
||
)
|
||
}>${start + i}</td>`
|
||
: ""
|
||
}${
|
||
image
|
||
? `<td width="${size[0]}" valign="top" style="padding-right:${
|
||
list.ordered ? 12 : 14
|
||
}px;">${
|
||
this.image(image.href, image.text, size[0], size[1], link?.href)
|
||
}</td>`
|
||
: ""
|
||
}<td valign="top" style="font-family:${this.font};">${content}</td></tr></table>`;
|
||
}).join("");
|
||
}
|
||
|
||
/** Returns the small uppercase line above the heading. */
|
||
private eyebrow(html: string): string {
|
||
return `<div${
|
||
this.paint(
|
||
{ fg: "muted" },
|
||
`font-family:${this.font};font-size:${
|
||
this.fs(12)
|
||
};font-weight:600;letter-spacing:0.12em;text-transform:uppercase;margin:0 0 8px;`,
|
||
)
|
||
}>${html}</div>`;
|
||
}
|
||
|
||
/** Returns a quoted comment or message. */
|
||
private quote(html: string): string {
|
||
return `<div${
|
||
this.paint(
|
||
{ fg: "quote" },
|
||
`font-family:${this.font};font-size:${
|
||
this.fs(14.5)
|
||
};line-height:1.5;font-style:italic;margin:4px 0 0;`,
|
||
)
|
||
}>“${html}”</div>`;
|
||
}
|
||
|
||
/** Returns a raw link set in a box, for clients that break buttons. */
|
||
private linkBox(html: string): string {
|
||
return `<div${
|
||
this.paint(
|
||
{ fg: "sec", bg: "soft" },
|
||
`font-family:Menlo,Consolas,'Courier New',monospace;font-size:12px;line-height:1.5;border-radius:${
|
||
Math.min(this.style.radius.img, 6)
|
||
}px;padding:10px 12px;margin:0 0 22px;word-break:break-all;`,
|
||
)
|
||
}>${html}</div>`;
|
||
}
|
||
|
||
/**
|
||
* Returns the whole HTML document: the brand, the card holding `body`, and
|
||
* the `footer`. `preheader` is the hidden first line inboxes show beside the
|
||
* subject.
|
||
*/
|
||
private document(
|
||
{ subject, preheader, body, footer }: {
|
||
subject: string;
|
||
preheader: string;
|
||
body: string;
|
||
footer: string;
|
||
},
|
||
): string {
|
||
const s = this.style;
|
||
const p = this.palette;
|
||
const R = s.radius.card;
|
||
const bar = s.bar ? p.gradient ?? null : null;
|
||
const frame = s.frame === "hard"
|
||
? `border:2px solid ${p.text};box-shadow:5px 5px 0 ${p.text};`
|
||
: s.frame === "ridge"
|
||
? `border:3px ridge ${p.border};`
|
||
: s.frame === "hairline"
|
||
? `border:1px solid ${p.rule};`
|
||
: s.frame === "soft"
|
||
? `box-shadow:0 14px 34px -10px ${p.shadow};`
|
||
: "";
|
||
const frameRoles = s.frame === "hard"
|
||
? { bd: "text" as const, extra: ["sh-card"] }
|
||
: s.frame === "ridge"
|
||
? { bd: "border" as const }
|
||
: s.frame === "hairline"
|
||
? { bd: "rule" as const }
|
||
: s.frame === "soft"
|
||
? { extra: ["sh-soft"] }
|
||
: {};
|
||
const scheme = this.darkPalette
|
||
? "light dark"
|
||
: p === s.dark
|
||
? "dark"
|
||
: "light";
|
||
|
||
return `<!doctype html>
|
||
<html lang="${EMAIL_LOCALE}">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="color-scheme" content="${scheme}">
|
||
<meta name="supported-color-schemes" content="${scheme}">
|
||
<title>${esc(subject)}</title>
|
||
${
|
||
s.webFont
|
||
? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${s.webFont}&display=swap">`
|
||
: ""
|
||
}
|
||
<style>
|
||
body{margin:0;padding:0;}
|
||
@media (max-width:620px){.wrap{padding:20px 10px 28px!important}.card{padding:24px 20px 8px!important}}
|
||
${this.darkCss()}
|
||
</style>
|
||
</head>
|
||
<body${this.paint({ bg: "outer" }, "margin:0;padding:0;")}>
|
||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${
|
||
esc(preheader)
|
||
}</div>
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"${
|
||
this.paint({ bg: "outer" })
|
||
}>
|
||
<tr><td align="center" class="wrap" style="padding:32px 16px 40px;">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
|
||
<tr><td style="padding:0 6px 16px;">
|
||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||
<td style="padding-right:10px;vertical-align:middle;font-size:24px;line-height:1;">${
|
||
esc(SITE_EMOJI)
|
||
}</td>
|
||
<td${
|
||
this.paint(
|
||
{ fg: p.brand ? "brand" : "text" },
|
||
`vertical-align:middle;font-family:${this.font};font-weight:700;font-size:${
|
||
this.fs(21)
|
||
};line-height:1;letter-spacing:-0.01em;`,
|
||
)
|
||
}>${esc(OG_SITE_NAME)}</td>
|
||
</tr></table>
|
||
</td></tr>
|
||
${
|
||
bar
|
||
? `<tr><td class="grad" style="height:5px;line-height:5px;font-size:0;background-color:${
|
||
bar[0]
|
||
};background-image:${
|
||
gradient(bar)
|
||
};border-radius:${R}px ${R}px 0 0;"> </td></tr>`
|
||
: ""
|
||
}
|
||
<tr><td${
|
||
this.paint(
|
||
{
|
||
bg: "card",
|
||
...frameRoles,
|
||
extra: ["card", ...(frameRoles.extra ?? [])],
|
||
},
|
||
`border-radius:${
|
||
bar ? `0 0 ${R}px ${R}px` : `${R}px`
|
||
};${frame}padding:34px 36px 12px;`,
|
||
)
|
||
}>
|
||
${body}
|
||
</td></tr>
|
||
<tr><td${
|
||
this.paint(
|
||
{ fg: "muted" },
|
||
`padding:22px 6px 0;font-family:${this.font};font-size:${
|
||
this.fs(12.5)
|
||
};line-height:1.6;`,
|
||
)
|
||
}>${footer}</td></tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
/**
|
||
* Returns the CSS that recolors every painted element in dark mode, or
|
||
* nothing off the "auto" scheme.
|
||
*/
|
||
private darkCss(): string {
|
||
const d = this.darkPalette;
|
||
if (!d) return "";
|
||
const rules: string[] = [];
|
||
for (const role of ROLES) {
|
||
const color = d[role];
|
||
if (!color) continue;
|
||
rules.push(
|
||
`.f-${role}{color:${color}!important}`,
|
||
`.b-${role}{background-color:${color}!important}`,
|
||
`.d-${role}{border-color:${color}!important}`,
|
||
);
|
||
}
|
||
rules.push(
|
||
`.sh-card{box-shadow:5px 5px 0 ${d.text}!important}`,
|
||
`.sh-btn{box-shadow:2px 2px 0 ${d.text}!important}`,
|
||
);
|
||
if (d.shadow) {
|
||
rules.push(
|
||
`.sh-soft{box-shadow:0 14px 34px -10px ${d.shadow}!important}`,
|
||
);
|
||
}
|
||
if (d.gradient) {
|
||
rules.push(
|
||
`.grad{background-color:${d.gradient[0]}!important;background-image:${
|
||
gradient(d.gradient)
|
||
}!important}`,
|
||
);
|
||
}
|
||
return `@media (prefers-color-scheme:dark){${rules.join("")}}`;
|
||
}
|
||
}
|