301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
/**
|
||
* Lets the emails draw with the site's own values. It reads a style's design
|
||
* tokens out of the stylesheets the client loads, `src/tokens.css` and then
|
||
* `src/themes/<style>.css`, so `styles.ts` names a token wherever it needs a
|
||
* color, a gradient, a font or a radius, and a change to a theme reaches its
|
||
* emails with nothing copied to keep in step.
|
||
*
|
||
* Mail clients know no custom properties, so a token comes back resolved for
|
||
* one scheme: its `var()`s followed, its `light-dark()` picked, its
|
||
* `color-mix()` computed. Only the rules that set tokens on the root element
|
||
* are read (`:root`, the style's `[data-style]`, and either of those with the
|
||
* scheme's `[data-color-scheme]`); a theme states its forced schemes that way
|
||
* beside each `prefers-color-scheme` block, so the media blocks are skipped.
|
||
*
|
||
* @module
|
||
*/
|
||
|
||
import { BASE_STYLE, type StyleName } from "../../shared/theme.ts";
|
||
|
||
/** A scheme a token is resolved for. "auto" is drawn from both. */
|
||
export type Scheme = "light" | "dark";
|
||
|
||
/** An sRGB color with its alpha, each channel 0–255 and alpha 0–1. */
|
||
interface Rgba {
|
||
r: number;
|
||
g: number;
|
||
b: number;
|
||
a: number;
|
||
}
|
||
|
||
/**
|
||
* Returned by {@link readTokens}: the tokens of one style in one scheme, read
|
||
* as the kind of value each is.
|
||
*/
|
||
export interface ThemeTokens {
|
||
/** Returns the resolved value of `token`, or throws when it isn't set. */
|
||
value(token: string): string;
|
||
/**
|
||
* Returns the color of `token` as `#rrggbb`. A translucent one is laid over
|
||
* `over` (another token) first, as it shows on the site; without `over` it
|
||
* stays `rgba()`, as a shadow's must.
|
||
*/
|
||
color(token: string, over?: string): string;
|
||
/** Returns the colors of the gradient `token`, in order, without positions. */
|
||
stops(token: string): string[];
|
||
/**
|
||
* Returns the font stack of `token`, single-quoted for a `style` attribute.
|
||
* A family the site bundles as "X Variable" is named "X", as a mail client
|
||
* or Google Fonts knows it.
|
||
*/
|
||
font(token: string): string;
|
||
/** Returns the length of `token` in pixels. */
|
||
px(token: string): number;
|
||
}
|
||
|
||
const SOURCES = ["../../src/tokens.css", "../../src/themes/{style}.css"];
|
||
|
||
/** Returns `css` without its comments. */
|
||
function stripComments(css: string): string {
|
||
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||
}
|
||
|
||
/** Returns the top-level rules of `css` as their selector and body. */
|
||
function topRules(css: string): { selector: string; body: string }[] {
|
||
const rules = [];
|
||
let i = 0;
|
||
while (i < css.length) {
|
||
const open = css.indexOf("{", i);
|
||
if (open < 0) break;
|
||
let depth = 1;
|
||
let j = open + 1;
|
||
while (depth && j < css.length) {
|
||
if (css[j] === "{") depth++;
|
||
else if (css[j] === "}") depth--;
|
||
j++;
|
||
}
|
||
rules.push({
|
||
selector: css.slice(i, open).trim(),
|
||
body: css.slice(open + 1, j - 1),
|
||
});
|
||
i = j;
|
||
}
|
||
return rules;
|
||
}
|
||
|
||
/**
|
||
* Splits `text` at each `sep` outside parentheses and quotes, trimming the
|
||
* parts: the arguments of a CSS function, or the declarations of a rule.
|
||
*/
|
||
function splitTop(text: string, sep: string): string[] {
|
||
const parts: string[] = [];
|
||
let depth = 0;
|
||
let quote = "";
|
||
let start = 0;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const c = text[i];
|
||
if (quote) {
|
||
if (c === quote) quote = "";
|
||
} else if (c === '"' || c === "'") quote = c;
|
||
else if (c === "(") depth++;
|
||
else if (c === ")") depth--;
|
||
else if (c === sep && depth === 0) {
|
||
parts.push(text.slice(start, i).trim());
|
||
start = i + 1;
|
||
}
|
||
}
|
||
parts.push(text.slice(start).trim());
|
||
return parts.filter((part) => part !== "");
|
||
}
|
||
|
||
/**
|
||
* Replaces each call of the CSS function `name` in `value`, innermost last
|
||
* first, with what `replace` returns for its arguments.
|
||
*/
|
||
function replaceCalls(
|
||
value: string,
|
||
name: string,
|
||
replace: (args: string[]) => string,
|
||
): string {
|
||
for (;;) {
|
||
const at = value.lastIndexOf(`${name}(`);
|
||
if (at < 0) return value;
|
||
let depth = 0;
|
||
let end = at + name.length;
|
||
do {
|
||
if (value[end] === "(") depth++;
|
||
else if (value[end] === ")") depth--;
|
||
end++;
|
||
} while (depth && end < value.length);
|
||
const args = splitTop(value.slice(at + name.length + 1, end - 1), ",");
|
||
value = value.slice(0, at) + replace(args) + value.slice(end);
|
||
}
|
||
}
|
||
|
||
/** Parses a CSS color in the forms the themes write, or throws. */
|
||
function parseColor(text: string): Rgba {
|
||
const value = text.trim().toLowerCase();
|
||
if (value === "transparent") return { r: 0, g: 0, b: 0, a: 0 };
|
||
const hex = value.match(/^#([0-9a-f]{3,8})$/)?.[1];
|
||
if (hex && hex.length !== 5 && hex.length !== 7) {
|
||
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
|
||
const channel = (i: number) => parseInt(full.slice(i * 2, i * 2 + 2), 16);
|
||
return {
|
||
r: channel(0),
|
||
g: channel(1),
|
||
b: channel(2),
|
||
a: full.length === 8 ? channel(3) / 255 : 1,
|
||
};
|
||
}
|
||
const rgb = value.match(/^rgba?\((.*)\)$/)?.[1];
|
||
if (rgb) {
|
||
const [r, g, b, a = "1"] = rgb.split(/[\s,/]+/).filter(Boolean);
|
||
const alpha = a.endsWith("%") ? parseFloat(a) / 100 : parseFloat(a);
|
||
return { r: +r, g: +g, b: +b, a: alpha };
|
||
}
|
||
throw new Error(`Unreadable color: ${text}`);
|
||
}
|
||
|
||
/** Writes `color` as `#rrggbb`, or as `rgba()` when it is translucent. */
|
||
function formatColor({ r, g, b, a }: Rgba): string {
|
||
const [R, G, B] = [r, g, b].map((c) => Math.round(c));
|
||
if (a < 1) return `rgba(${R},${G},${B},${+a.toFixed(3)})`;
|
||
return `#${[R, G, B].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
||
}
|
||
|
||
/** Returns `top` laid over `under`, both straight (not premultiplied) alpha. */
|
||
function over(top: Rgba, under: Rgba): Rgba {
|
||
const a = top.a + under.a * (1 - top.a);
|
||
if (a === 0) return { r: 0, g: 0, b: 0, a: 0 };
|
||
const mix = (t: number, u: number) =>
|
||
(t * top.a + u * under.a * (1 - top.a)) / a;
|
||
return {
|
||
r: mix(top.r, under.r),
|
||
g: mix(top.g, under.g),
|
||
b: mix(top.b, under.b),
|
||
a,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Computes `color-mix(in srgb, …)` from its arguments, the two colors with
|
||
* their optional percentages, as CSS does: premultiplied, and scaled down
|
||
* when the percentages sum under 100%.
|
||
*/
|
||
function colorMix(args: string[]): string {
|
||
const [space, ...colors] = args;
|
||
if (space !== "in srgb" || colors.length !== 2) {
|
||
throw new Error(`Unsupported color-mix: ${args.join(", ")}`);
|
||
}
|
||
const parsed = colors.map((arg) => {
|
||
const pct = arg.match(/\s([\d.]+)%$/);
|
||
return {
|
||
color: parseColor(pct ? arg.slice(0, pct.index) : arg),
|
||
pct: pct ? parseFloat(pct[1]) / 100 : null,
|
||
};
|
||
});
|
||
const [p1, p2] = [
|
||
parsed[0].pct ?? 1 - (parsed[1].pct ?? 0.5),
|
||
parsed[1].pct ?? 1 - (parsed[0].pct ?? 0.5),
|
||
];
|
||
const sum = p1 + p2;
|
||
const [w1, w2] = [p1 / sum, p2 / sum];
|
||
const [c1, c2] = [parsed[0].color, parsed[1].color];
|
||
const a = c1.a * w1 + c2.a * w2;
|
||
const mix = (x: number, y: number) =>
|
||
a === 0 ? 0 : (x * c1.a * w1 + y * c2.a * w2) / a;
|
||
return formatColor({
|
||
r: mix(c1.r, c2.r),
|
||
g: mix(c1.g, c2.g),
|
||
b: mix(c1.b, c2.b),
|
||
a: a * Math.min(sum, 1),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Returns the tokens of `style` in `scheme`, read from the site's stylesheets
|
||
* at the time of the call. `styles.ts` reads each style once, at load.
|
||
*/
|
||
export function readTokens(style: StyleName, scheme: Scheme): ThemeTokens {
|
||
const styleSel = style === BASE_STYLE
|
||
? `(?:\\[data-style="${style}"\\]|:not\\(\\[data-style\\]\\))`
|
||
: `\\[data-style="${style}"\\]`;
|
||
const rootRule = new RegExp(
|
||
`^(?::root)?(?:${styleSel})?(?:\\[data-color-scheme="${scheme}"\\])?$`,
|
||
);
|
||
|
||
// Later declarations win, as in the cascade: the base tokens, then the
|
||
// theme's, then its forced scheme's.
|
||
const declared = new Map<string, string>();
|
||
for (const source of SOURCES) {
|
||
const css = stripComments(Deno.readTextFileSync(
|
||
new URL(source.replace("{style}", style), import.meta.url),
|
||
));
|
||
for (const { selector, body } of topRules(css)) {
|
||
if (selector.startsWith("@")) continue;
|
||
if (!selector.split(",").some((part) => rootRule.test(part.trim()))) {
|
||
continue;
|
||
}
|
||
for (const declaration of splitTop(body, ";")) {
|
||
const colon = declaration.indexOf(":");
|
||
const name = declaration.slice(0, colon).trim();
|
||
if (!name.startsWith("--")) continue;
|
||
declared.set(
|
||
name,
|
||
declaration.slice(colon + 1).replace(/\s+/g, " ").trim(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const resolved = new Map<string, string>();
|
||
const value = (token: string): string => {
|
||
const known = resolved.get(token);
|
||
if (known !== undefined) return known;
|
||
const raw = declared.get(token);
|
||
if (raw === undefined) throw new Error(`${style} sets no ${token}`);
|
||
let result = replaceCalls(
|
||
raw,
|
||
"var",
|
||
([name, fallback]) =>
|
||
declared.has(name) ? value(name) : fallback ?? value(name),
|
||
);
|
||
result = replaceCalls(
|
||
result,
|
||
"light-dark",
|
||
([light, dark]) => scheme === "light" ? light : dark,
|
||
);
|
||
result = replaceCalls(result, "color-mix", colorMix);
|
||
resolved.set(token, result);
|
||
return result;
|
||
};
|
||
|
||
return {
|
||
value,
|
||
color(token, under) {
|
||
const color = parseColor(value(token));
|
||
return formatColor(under ? over(color, parseColor(value(under))) : color);
|
||
},
|
||
stops(token) {
|
||
const gradient = value(token).match(/^linear-gradient\((.*)\)$/)?.[1];
|
||
if (!gradient) throw new Error(`${style} ${token} is no linear gradient`);
|
||
return splitTop(gradient, ",")
|
||
.filter((stop) => !/^(?:to\s|[\d.-]+(?:deg|turn|rad)$)/.test(stop))
|
||
.map((stop) => formatColor(parseColor(splitTop(stop, " ")[0])));
|
||
},
|
||
font(token) {
|
||
return splitTop(value(token), ",")
|
||
.map((family) => family.replace(/["']/g, "").replace(/ Variable$/, ""))
|
||
.map((family) => /^[\w-]+$/.test(family) ? family : `'${family}'`)
|
||
.join(", ");
|
||
},
|
||
px(token) {
|
||
const length = value(token).match(/^([\d.]+)(?:px)?$/)?.[1];
|
||
if (length === undefined) {
|
||
throw new Error(`${style} ${token} is no pixel length`);
|
||
}
|
||
return parseFloat(length);
|
||
},
|
||
};
|
||
}
|