/** * 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 | 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; 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 `${html}`; } /** Returns the email's heading, in the style's title type. */ heading(html: string, marginBottom = 24): string { const t = this.style.title; return `${html}`; } /** Returns a paragraph. `css` adds to or overrides its style. */ para(html: string, css = ""): string { return `${html}

`; } /** Returns a smaller paragraph in the muted color. */ note(html: string, css = ""): string { return `${html}

`; } /** 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 `${label}
`; } /** Returns a small uppercase pill, as on one's own dump in a list. */ pill(label: string): string { return `${label}`; } /** Returns an image block, rounded as the style rounds images. */ image( src: string, alt: string, width: number, height: number, href?: string, ): string { const img = `${
      esc(alt)
    }`; return href ? `${img}` : img; } /** Returns a small bold heading inside a section. */ subhead(html: string): string { return `${html}`; } /** * 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 = `
 
`; } else { border = `border-top:${ s.rule === "thick" ? 2 : 1 }px solid;padding-top:26px;`; } } return `${divider}${title}${html}`; } /** * 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("
"), }), 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" ? `${html}` : kit.link(html, href); }, strong({ tokens }) { const html = this.parser.parseInline(tokens); const figure = /^[\d\s.,  ]+$/.test(html); return `${html}`; }, em({ tokens }) { const html = this.parser.parseInline(tokens); if (kit.mode === "row") return kit.pill(html); if (kit.mode === "sentence") { return `${html}`; } return `${html}`; }, codespan({ text }) { return `${text}`; }, 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) }
 
`, ); } 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 = `${this.inline(link.tokens, "text")}${this.inline(pills, "row")}${ rest.length ? `${this.inline(rest, "text")}` : "" }`; } else { content = `${this.inline(rest, "sentence")}`; } 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 `${ list.ordered ? `` : "" }${ image ? `` : "" }
${start + i}${ this.image(image.href, image.text, size[0], size[1], link?.href) }${content}
`; }).join(""); } /** Returns the small uppercase line above the heading. */ private eyebrow(html: string): string { return `${html}`; } /** Returns a quoted comment or message. */ private quote(html: string): string { return `“${html}”`; } /** Returns a raw link set in a box, for clients that break buttons. */ private linkBox(html: string): string { return `${html}`; } /** * 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 ` ${esc(subject)} ${ s.webFont ? `` : "" }
${ esc(preheader) }
${ bar ? `` : "" } ${body} ${footer}
${esc(OG_SITE_NAME)}
${ esc(SITE_EMOJI) }
 
`; } /** * 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("")}}`; } }