277 lines
9.2 KiB
TypeScript
277 lines
9.2 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";
|
|
import { hexOf, parseCssColor, type Rgba } from "../lib/css-color.ts";
|
|
|
|
/** A scheme a token is resolved for. "auto" is drawn from both. */
|
|
export type Scheme = "light" | "dark";
|
|
|
|
/**
|
|
* 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 color = parseCssColor(text);
|
|
if (!color) throw new Error(`Unreadable color: ${text}`);
|
|
return color;
|
|
}
|
|
|
|
/** Writes `color` as `#rrggbb`, or as `rgba()` when it is translucent. */
|
|
function formatColor(color: Rgba): string {
|
|
if (color.a >= 1) return hexOf(color);
|
|
const [r, g, b] = [color.r, color.g, color.b].map(Math.round);
|
|
return `rgba(${r},${g},${b},${+color.a.toFixed(3)})`;
|
|
}
|
|
|
|
/** 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);
|
|
},
|
|
};
|
|
}
|