Files
SRCmail/lib/theme-compiler.ts

296 lines
9.4 KiB
TypeScript

// Advanced Theme API v2 - compiles structured manifest fields (tokens,
// radii, typography, density, extends) into a single CSS string that the
// existing `injectThemeCSS` pipeline can apply unchanged.
import type {
ThemeDensity,
ThemeManifest,
ThemeRadii,
ThemeTokenSet,
ThemeTypography,
} from './plugin-types';
import { getLuminance, parseColor } from './color-transform';
export interface CompiledTheme {
css: string;
warnings: string[];
errors: string[];
}
/**
* Standard tokens whose `*-foreground` counterpart can be auto-derived from
* contrast when `derive: true` and only the base colour is supplied.
*/
const DERIVE_PAIRS: Array<[base: string, fg: string]> = [
['primary', 'primary-foreground'],
['secondary', 'secondary-foreground'],
['muted', 'muted-foreground'],
['accent', 'accent-foreground'],
['destructive', 'destructive-foreground'],
['popover', 'popover-foreground'],
['card', 'card-foreground'],
['sidebar', 'sidebar-foreground'],
['success', 'success-foreground'],
['warning', 'warning-foreground'],
['info', 'info-foreground'],
];
/** Pick a foreground colour (white or near-black) by background luminance. */
function pickForeground(bg: string): string {
const rgb = parseColor(bg);
if (!rgb) return '#ffffff';
return getLuminance(rgb.r, rgb.g, rgb.b) >= 0.55 ? '#0f172a' : '#ffffff';
}
/**
* Resolve a manifest token key to a fully-qualified CSS custom property:
* "primary" → "--color-primary"
* "color-primary" → "--color-primary"
* "--color-primary" → "--color-primary"
* "font-sans" → "--font-sans"
*/
const PREFIXED_NAMESPACES = ['color-', 'font-', 'radius-', 'density-'];
function tokenName(key: string): string {
if (key.startsWith('--')) return key;
if (PREFIXED_NAMESPACES.some((ns) => key.startsWith(ns))) return `--${key}`;
return `--color-${key}`;
}
function emitTokens(
tokens: Record<string, string>,
derive: boolean,
): { lines: string[]; warnings: string[] } {
const warnings: string[] = [];
const expanded: Record<string, string> = { ...tokens };
if (derive) {
for (const [base, fg] of DERIVE_PAIRS) {
if (expanded[base] && !expanded[fg]) {
expanded[fg] = pickForeground(expanded[base]);
}
}
// Common alias: --color-foreground used as page text colour.
if (expanded.background && !expanded.foreground) {
expanded.foreground = pickForeground(expanded.background);
}
}
const lines: string[] = [];
for (const [rawKey, value] of Object.entries(expanded)) {
if (typeof value !== 'string' || !value.trim()) continue;
if (!isSafeTokenKey(rawKey)) {
warnings.push(`Token "${rawKey}" dropped - invalid key (only [a-z0-9-] allowed)`);
continue;
}
if (!isSafeTokenValue(value)) {
warnings.push(`Token "${rawKey}" dropped - value contains unsafe characters`);
continue;
}
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
}
return { lines, warnings };
}
const SAFE_KEY_PATTERN = /^(--)?[a-z][a-z0-9-]*$/;
function isSafeTokenKey(key: string): boolean {
return SAFE_KEY_PATTERN.test(key);
}
/**
* Token values are emitted verbatim into CSS, so they must not contain
* anything that could break out of the declaration (`{`, `}`, `;`,
* `<`/`>`) or pull in remote/scripted content.
*/
function isSafeTokenValue(value: string): boolean {
if (/[{}<>]/.test(value)) return false;
if (value.includes(';')) return false;
if (/url\s*\(\s*['"]?(https?|data|javascript):/i.test(value)) return false;
if (/expression\s*\(/i.test(value)) return false;
if (/-moz-binding/i.test(value)) return false;
if (/javascript\s*:/i.test(value)) return false;
return true;
}
function emitRadii(radii: ThemeRadii): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(radii)) {
if (typeof v === 'string' && isSafeTokenValue(v)) {
out.push(` --radius-${k}: ${v.trim()};`);
}
}
return out;
}
function emitTypography(typography: ThemeTypography): string[] {
const out: string[] = [];
if (typography.fontSans && isSafeTokenValue(typography.fontSans)) {
out.push(` --font-sans: ${typography.fontSans.trim()};`);
}
if (typography.fontMono && isSafeTokenValue(typography.fontMono)) {
out.push(` --font-mono: ${typography.fontMono.trim()};`);
}
if (typography.fontDisplay && isSafeTokenValue(typography.fontDisplay)) {
out.push(` --font-display: ${typography.fontDisplay.trim()};`);
}
if (typography.baseFontSize && isSafeTokenValue(typography.baseFontSize)) {
out.push(` --font-size-base: ${typography.baseFontSize.trim()};`);
}
return out;
}
const DENSITY_VARS: Record<ThemeDensity, Record<string, string>> = {
compact: {
'--density-row-height': '28px',
'--density-control-height': '28px',
'--density-spacing-1': '2px',
'--density-spacing-2': '4px',
'--density-spacing-3': '6px',
},
normal: {
'--density-row-height': '36px',
'--density-control-height': '32px',
'--density-spacing-1': '4px',
'--density-spacing-2': '8px',
'--density-spacing-3': '12px',
},
touch: {
'--density-row-height': '44px',
'--density-control-height': '40px',
'--density-spacing-1': '6px',
'--density-spacing-2': '12px',
'--density-spacing-3': '18px',
},
};
function emitDensity(density: ThemeDensity): string[] {
return Object.entries(DENSITY_VARS[density]).map(([k, v]) => ` ${k}: ${v};`);
}
export interface CompileOptions {
/**
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
* Implementations should return null for unknown ids; circular refs are
* the caller's problem (we don't recurse - just one level of inheritance).
*/
resolveExtends?: (id: string) => string | null;
/**
* Optional hand-written CSS appended after compiled tokens. Use this for
* the rare overrides the structured API can't express (extra `@font-face`,
* `@keyframes`, `@media (prefers-contrast)` blocks, etc.).
*/
userCSS?: string;
}
/**
* Compile an advanced theme manifest into a single safe CSS string.
*
* Output layout:
* 1. parent (extends) CSS, if any
* 2. `:root { common + light + radii + typography + density }`
* 3. `.dark { common + dark }` (only when the theme declares a dark variant)
* 4. user-supplied `theme.css` content (sanitized upstream)
*
* The compiler never emits selectors other than `:root` and `.dark`, so the
* existing CSS sanitizer/selector validator continues to apply.
*/
export function compileAdvancedTheme(
manifest: ThemeManifest,
opts: CompileOptions = {},
): CompiledTheme {
const warnings: string[] = [];
const errors: string[] = [];
if (!isAdvancedManifest(manifest)) {
return { css: '', warnings, errors: ['Manifest does not declare any advanced theme fields'] };
}
const tokens: ThemeTokenSet = manifest.tokens ?? {};
const derive = manifest.derive === true;
const wantsDark = manifest.variants.includes('dark');
const wantsLight = manifest.variants.includes('light');
const sections: string[] = [];
// 1. extends - prepend parent CSS verbatim
if (manifest.extends && opts.resolveExtends) {
const parentCSS = opts.resolveExtends(manifest.extends);
if (parentCSS == null) {
warnings.push(`extends: parent theme "${manifest.extends}" not found - skipping`);
} else {
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
}
} else if (manifest.extends) {
warnings.push(`extends: no resolver provided - "${manifest.extends}" ignored`);
}
// 2. :root block (light + common + structural)
const rootLines: string[] = [];
if (tokens.common) {
const { lines, warnings: w } = emitTokens(tokens.common, derive);
rootLines.push(...lines);
warnings.push(...w);
}
if (wantsLight && tokens.light) {
const { lines, warnings: w } = emitTokens(tokens.light, derive);
rootLines.push(...lines);
warnings.push(...w);
}
if (manifest.radii) rootLines.push(...emitRadii(manifest.radii));
if (manifest.typography) rootLines.push(...emitTypography(manifest.typography));
if (manifest.density) rootLines.push(...emitDensity(manifest.density));
if (rootLines.length > 0) {
sections.push(`:root {\n${rootLines.join('\n')}\n}`);
}
// 3. .dark block
if (wantsDark) {
const darkLines: string[] = [];
if (tokens.common) {
const { lines, warnings: w } = emitTokens(tokens.common, derive);
darkLines.push(...lines);
warnings.push(...w);
}
if (tokens.dark) {
const { lines, warnings: w } = emitTokens(tokens.dark, derive);
darkLines.push(...lines);
warnings.push(...w);
}
if (darkLines.length > 0) {
sections.push(`.dark {\n${darkLines.join('\n')}\n}`);
}
}
// 4. hand-written overrides
if (opts.userCSS && opts.userCSS.trim()) {
sections.push(`/* user overrides */\n${opts.userCSS.trim()}`);
}
if (sections.length === 0) {
errors.push('Compiled theme is empty - no tokens, radii, typography, or density supplied');
}
return {
css: sections.join('\n\n'),
warnings,
errors,
};
}
/**
* True if a manifest opts into Theme API v2 by setting `apiVersion: 2` or by
* declaring any of the structured fields.
*/
export function isAdvancedManifest(manifest: ThemeManifest): boolean {
return (
manifest.apiVersion === 2 ||
!!manifest.tokens ||
!!manifest.extends ||
!!manifest.derive ||
!!manifest.density ||
!!manifest.radii ||
!!manifest.typography
);
}