Files
CherryHQ-cherry-studio/packages/ui/scripts/build-theme-css.ts
2026-06-17 13:26:53 +08:00

183 lines
5.9 KiB
TypeScript

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<ThemeContractInputs> {
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<void> {
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()
}