823 lines
26 KiB
TypeScript
823 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. */
|
|
// The kit comes in as an argument: the renderer's methods need marked's
|
|
// `this` (its parser) as well as the kit's colors.
|
|
private readonly marked: Marked = ((kit: EmailKit) => {
|
|
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);
|
|
// A figure, its digits grouped by any space: `\s` takes the no-break
|
|
// ones locales group thousands with.
|
|
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);
|
|
},
|
|
},
|
|
});
|
|
})(this);
|
|
|
|
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("")}}`;
|
|
}
|
|
}
|