/** * Reads the CSS colors the server meets: a dumped page's `theme-color`, which * the generic link preview keeps as its tint (`normalizeCssColor`), and the * theme stylesheets' tokens, which the emails resolve (`theme-tokens.ts`). * * @module */ /** An sRGB color with its alpha, each channel 0–255 and alpha 0–1. */ export interface Rgba { r: number; g: number; b: number; a: number; } /** * The named colors sites actually put in `theme-color`, and `transparent`, * which the themes write; the rest of CSS's names aren't worth carrying. */ const NAMED: Record = { transparent: "#00000000", black: "#000000", white: "#ffffff", red: "#ff0000", green: "#008000", blue: "#0000ff", yellow: "#ffff00", orange: "#ffa500", purple: "#800080", gray: "#808080", grey: "#808080", silver: "#c0c0c0", maroon: "#800000", navy: "#000080", teal: "#008080", olive: "#808000", lime: "#00ff00", aqua: "#00ffff", cyan: "#00ffff", fuchsia: "#ff00ff", magenta: "#ff00ff", }; /** Reads a channel of `rgb()`, a number or a percentage of 255. */ function channel(text: string): number { const n = parseFloat(text); const value = text.endsWith("%") ? (n / 100) * 255 : n; return Math.max(0, Math.min(255, value)); } /** Reads the alpha of `rgba()`, a number or a percentage of 1. */ function alpha(text: string): number { const n = parseFloat(text); return Math.max(0, Math.min(1, text.endsWith("%") ? n / 100 : n)); } /** * Returns the color `text` writes as a hex (3, 4, 6 or 8 digits), an `rgb()` * or `rgba()` of numbers or percentages, or one of the common names; undefined * for any other form. Channels out of range are clamped. */ export function parseCssColor(text: string): Rgba | undefined { const value = text.trim().toLowerCase(); const hex = /^#([0-9a-f]{3,8})$/.exec(NAMED[value] ?? value)?.[1]; if (hex) { if (hex.length === 5 || hex.length === 7) return undefined; const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex; const byte = (i: number) => parseInt(full.slice(i * 2, i * 2 + 2), 16); return { r: byte(0), g: byte(1), b: byte(2), a: full.length === 8 ? byte(3) / 255 : 1, }; } const args = /^rgba?\(([^)]+)\)$/.exec(value)?.[1]; if (!args) return undefined; const parts = args.split(/[\s,/]+/).filter(Boolean); if (parts.length !== 3 && parts.length !== 4) return undefined; const [r, g, b] = parts.slice(0, 3).map(channel); const a = parts[3] === undefined ? 1 : alpha(parts[3]); return [r, g, b, a].some(Number.isNaN) ? undefined : { r, g, b, a }; } /** Writes `color` as a lowercase `#rrggbb`, its alpha dropped. */ export function hexOf({ r, g, b }: Rgba): string { return `#${ [r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("") }`; }