import { promises as fs } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const STYLES_DIR = path.resolve(__dirname, '../src/styles') const THEME_OUTPUT_PATH = path.join(STYLES_DIR, 'theme.css') const RUNTIME_THEME_INPUT_LINES = [ '--cs-theme-primary: var(--cs-primary);', '--cs-theme-ring: color-mix(in srgb, var(--cs-theme-primary) 40%, transparent);' ] const COMPATIBILITY_ALIAS_LINES = ['--primary: var(--color-primary);', '--ring: var(--color-ring);'] const PRIMARY_SEMANTIC_LINES = [ '--color-primary: var(--cs-theme-primary);', '--color-primary-hover: var(--cs-primary-hover);', '--color-primary-soft: color-mix(in srgb, var(--color-primary) 60%, transparent);', '--color-primary-mute: color-mix(in srgb, var(--color-primary) 30%, transparent);', '--color-ring: var(--cs-theme-ring);' ] const SPACING_COMMENT_LINES = [ '/* Keep spacing opt-in for now to avoid overriding Tailwind container names. */', '/* --spacing-5xs: var(--cs-size-5xs);', '--spacing-4xs: var(--cs-size-4xs);', '--spacing-3xs: var(--cs-size-3xs);', '--spacing-2xs: var(--cs-size-2xs);', '--spacing-xs: var(--cs-size-xs);', '--spacing-sm: var(--cs-size-sm);', '--spacing-md: var(--cs-size-md);', '--spacing-lg: var(--cs-size-lg);', '--spacing-xl: var(--cs-size-xl);', '--spacing-2xl: var(--cs-size-2xl);', '--spacing-3xl: var(--cs-size-3xl);', '--spacing-4xl: var(--cs-size-4xl);', '--spacing-5xl: var(--cs-size-5xl);', '--spacing-6xl: var(--cs-size-6xl);', '--spacing-7xl: var(--cs-size-7xl);', '--spacing-8xl: var(--cs-size-8xl); */' ] const ANIMATION_LINES = [ '--animate-checkbox-bounce: checkbox-bounce 300ms cubic-bezier(0.4, 0, 0.2, 1);', '--animate-checkbox-icon-in: checkbox-icon-in 160ms ease-out both;', '', '@keyframes checkbox-bounce {', ' 0%,', ' 100% {', ' transform: scale(1);', ' }', '', ' 50% {', ' transform: scale(1.08);', ' }', '}', '', '@keyframes checkbox-icon-in {', ' from {', ' opacity: 0;', ' transform: scale(0.75);', ' }', '', ' to {', ' opacity: 1;', ' transform: scale(1);', ' }', '}' ] export interface ThemeContractInputs { primitiveColors: string[] semanticColors: string[] statusColors: string[] radiusTokens: string[] typographyTokens: string[] } function dedupe(values: string[]): string[] { return [...new Set(values)] } export function extractTokenNames(source: string): string[] { return dedupe([...source.matchAll(/^\s*--cs-([a-z0-9-]+)\s*:/gm)].map((match) => match[1])) } function toPrefixedMappings(tokenNames: string[], targetPrefix: string, sourcePrefix = '--cs-'): string[] { return tokenNames.map((tokenName) => `--${targetPrefix}${tokenName}: var(${sourcePrefix}${tokenName});`) } function toDirectMappings(tokenNames: string[], sourcePrefix = '--cs-'): string[] { return tokenNames.map((tokenName) => `--${tokenName}: var(${sourcePrefix}${tokenName});`) } function buildSection(title: string, lines: string[]): string { const indentedLines = lines.map((line) => (line ? ` ${line}` : '')).join('\n') return ` /* ==================== */\n /* ${title} */\n /* ==================== */\n${indentedLines}` } export function buildThemeContractCss(inputs: ThemeContractInputs): string { const semanticContractTokens = inputs.semanticColors.filter( (token) => !['primary', 'primary-hover', 'ring'].includes(token) ) const sections = [ buildSection('Primitive Colors', toPrefixedMappings(inputs.primitiveColors, 'color-')), buildSection('Runtime Theme Inputs', RUNTIME_THEME_INPUT_LINES), buildSection('Compatibility Aliases', COMPATIBILITY_ALIAS_LINES), buildSection('Semantic Colors', [ ...PRIMARY_SEMANTIC_LINES, ...toPrefixedMappings(semanticContractTokens, 'color-') ]), buildSection('Status Colors', toPrefixedMappings(inputs.statusColors, 'color-')), buildSection('Spacing', SPACING_COMMENT_LINES), buildSection('Radius', toDirectMappings(inputs.radiusTokens)), buildSection('Typography', toDirectMappings(inputs.typographyTokens)), buildSection('Animation', ANIMATION_LINES) ] return `/** * Generated from design tokens. * * ⚠️ DO NOT EDIT DIRECTLY! * This file is generated by \`pnpm theme:build\`. * Update \`src/styles/tokens/*\` to change the design source. */ @import './tokens.css'; @theme { ${sections.join('\n\n')} } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } ` } export async function loadThemeContractInputs(stylesDir = STYLES_DIR): Promise { const tokensDir = path.join(stylesDir, 'tokens') const [primitiveColorsSource, semanticColorsSource, statusColorsSource, radiusSource, typographySource] = await Promise.all([ fs.readFile(path.join(tokensDir, 'colors/primitive.css'), 'utf8'), fs.readFile(path.join(tokensDir, 'colors/semantic.css'), 'utf8'), fs.readFile(path.join(tokensDir, 'colors/status.css'), 'utf8'), fs.readFile(path.join(tokensDir, 'radius.css'), 'utf8'), fs.readFile(path.join(tokensDir, 'typography.css'), 'utf8') ]) return { primitiveColors: extractTokenNames(primitiveColorsSource), semanticColors: extractTokenNames(semanticColorsSource), statusColors: extractTokenNames(statusColorsSource), radiusTokens: extractTokenNames(radiusSource), typographyTokens: extractTokenNames(typographySource) } } export async function writeThemeContractCss(outputPath = THEME_OUTPUT_PATH, stylesDir = STYLES_DIR): Promise { const inputs = await loadThemeContractInputs(stylesDir) const css = buildThemeContractCss(inputs) await fs.writeFile(outputPath, css, 'utf8') } async function main() { await writeThemeContractCss() } if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { void main() }