feat: add Theme API v2 with token compiler, skin slot
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
// 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user