import { type DesignTokens, defaultTokens, tokensToCssVars } from '@sxo/design'; import { allRules } from './core/rules'; import type { Rule, RuleContext } from './core/types'; import { SxoParser } from './parser'; export interface EngineOptions { debug?: boolean; unit?: 'px' | 'rpx' | 'rem'; } export class StyleEngine { private rules: Rule[] = [...allRules]; private cache: Map = new Map(); private generatedClasses: Set = new Set(); private parser = new SxoParser(); private options: EngineOptions; private missedClasses: Set = new Set(); private variants: Record = { hover: { suffix: ':hover' }, focus: { suffix: ':focus' }, active: { suffix: ':active' }, disabled: { suffix: ':disabled' }, 'focus-within': { suffix: ':focus-within' }, 'focus-visible': { suffix: ':focus-visible' }, placeholder: { suffix: '::placeholder' }, selection: { suffix: '::selection' }, 'group-hover': { parent: '.group:hover' }, 'group-focus': { parent: '.group:focus' }, 'group-active': { parent: '.group:active' }, dark: { parent: '[data-sxo-mode="dark"]' }, light: { parent: '[data-sxo-mode="light"]' }, }; private tokens: DesignTokens; constructor(tokens: DesignTokens = defaultTokens, options: EngineOptions = {}) { this.tokens = tokens || defaultTokens; this.options = options; } /** * Add custom rules to the engine */ addRules(rules: Rule[]) { this.rules.unshift(...rules); } /** * Generate CSS for a single class name, supporting variants */ generate(className: string): string { if (this.cache.has(className)) { return this.cache.get(className)!; } // 1. Transform: Parse class name into structured AST const parsed = this.parser.parse(className); // 2. Resolve: Match rules to get raw CSS properties const styles = this.resolveStyles(parsed); if (!styles) { if (this.options.debug) { this.missedClasses.add(className); } return ''; } // 2.5 Handle negative values if (parsed.negative && typeof styles === 'object') { for (const key in styles) { const val = styles[key]; if (typeof val === 'string') { if (/^-?\d/.test(val)) { styles[key] = val.startsWith('-') ? val.substring(1) : `-${val}`; } else if (val.startsWith('var(')) { styles[key] = `calc(${val} * -1)`; } } } } // 3. Variant: Process variants (responsive, states) const variantInfo = this.resolveVariants(parsed); // 4. CodeGen: Format final CSS string const css = this.formatCss( className, styles, variantInfo.suffix, variantInfo.parent, variantInfo.mediaQuery, parsed.important, ); this.cache.set(className, css); this.generatedClasses.add(className); return css; } private resolveStyles(parsed: any): string | Record | undefined { const context: RuleContext = { tokens: this.tokens, parsed, unit: this.options.unit, }; const rawUtility = parsed.utility; for (const [match, handler] of this.rules) { let isMatch = false; let matchResult: string[] = []; if (typeof match === 'string') { isMatch = match === rawUtility; matchResult = [rawUtility]; } else if (match instanceof RegExp) { const m = rawUtility.match(match); if (m) { isMatch = true; matchResult = Array.from(m); } } else if (typeof match === 'function') { isMatch = match(parsed); matchResult = [rawUtility]; } if (isMatch) { const result = handler(matchResult, context); if (result) return result; } } return undefined; } private resolveVariants(parsed: any) { let suffix = ''; let parent = ''; const mediaQueries: string[] = []; for (const variant of parsed.variants) { const breakpoint = this.tokens?.breakpoints?.[variant]; if (breakpoint) { const bpStr = String(breakpoint); if (bpStr.startsWith('@media')) { mediaQueries.push(bpStr.replace(/^@media\s+/, '')); } else { mediaQueries.push(`(min-width: ${bpStr})`); } continue; } const config = this.variants[variant]; if (config) { if (config.suffix) suffix += config.suffix; if (config.parent) { parent = parent ? `${config.parent} ${parent}` : config.parent; } continue; } // Handle arbitrary variants: [&_...]:... if (variant.startsWith('[') && variant.endsWith(']')) { const inner = variant.substring(1, variant.length - 1).replace(/_/g, ' '); if (inner.includes('&')) { parent = parent ? `${inner} ${parent}` : inner; continue; } } if (this.options.debug) { console.warn(`[SXO] Unknown variant: ${variant} in class ${parsed.raw}`); } } return { suffix, parent, mediaQuery: mediaQueries.join(' and '), }; } /** * 获取未生成的类名列表 (Debug 模式) */ getMissedClasses(): string[] { return Array.from(this.missedClasses); } /** * Generate CSS for a set of class names */ generateBatch(classNames: string[] | Set): string { return this.generateSheet(classNames); } /** * Generate CSS for a set of class names */ generateSheet(classNames: string[] | Set): string { let css = ''; const breakpoints = this.tokens?.breakpoints || {}; const bpNames = Object.keys(breakpoints); const sortedClasses = Array.from(classNames).sort((a, b) => { const getBreakpoint = (cls: string) => { const parts = cls.split(':'); for (const part of parts) { if (bpNames.includes(part)) return part; } return undefined; }; const aBp = getBreakpoint(a); const bBp = getBreakpoint(b); // 1. Both are responsive if (aBp && bBp) { const aIdx = bpNames.indexOf(aBp); const bIdx = bpNames.indexOf(bBp); if (aIdx !== bIdx) return aIdx - bIdx; } // 2. One is responsive, one is not if (aBp && !bBp) return 1; if (!aBp && bBp) return -1; // 3. Neither is responsive, check for status variants const aIsVariant = a.includes(':'); const bIsVariant = b.includes(':'); if (aIsVariant !== bIsVariant) return aIsVariant ? 1 : -1; // 4. Fallback to alphabetical return a.localeCompare(b); }); for (const className of sortedClasses) { const result = this.generate(className); if (result) { css += `${result}\n`; } } return css; } /** * 生成所有设计令牌对应的 CSS 变量 */ generateTokenCssVars(): string { const { modes, ...baseTokens } = (this.tokens || {}) as any; const vars = tokensToCssVars(baseTokens); const body = Object.entries(vars) .map(([name, value]) => ` ${name}: ${value};`) .join('\n'); let darkVars = ''; if (modes?.dark) { const darkVarsObj = tokensToCssVars({ color: modes.dark }); darkVars = Object.entries(darkVarsObj) .map(([name, value]) => ` ${name}: ${value};`) .join('\n'); } const reset = ` *, ::before, ::after { box-sizing: border-box; border-width: 0; border-style: solid; border-color: currentColor; } html { line-height: 1.5; -webkit-text-size-adjust: 100%; -moz-tab-size: 4; tab-size: 4; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } body { margin: 0; line-height: inherit; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } button, input, optgroup, select, textarea { font-family: inherit; font-size: 100%; font-weight: inherit; line-height: inherit; color: inherit; margin: 0; padding: 0; } button, select { text-transform: none; } button, [type='button'], [type='reset'], [type='submit'] { -webkit-appearance: button; background-color: transparent; background-image: none; border: none; } :-moz-focusring { outline: auto; } :-moz-ui-invalid { box-shadow: none; } progress { vertical-align: baseline; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [type='search'] { -webkit-appearance: textfield; outline-offset: -2px; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-file-upload-button { -webkit-appearance: button; font: inherit; } summary { display: list-item; } blockquote, dl, dd, h1, h2, h3, h4, h5, h6, hr, figure, p, pre { margin: 0; } fieldset { margin: 0; padding: 0; } legend { padding: 0; } ol, ul, menu { list-style: none; margin: 0; padding: 0; } textarea { resize: vertical; } input::placeholder, textarea::placeholder { opacity: 1; color: #9ca3af; } button, [role="button"] { cursor: pointer; } :disabled { cursor: default; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } [hidden] { display: none; } `; const animations = ` @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes slide-in-top { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes slide-in-bottom { from { transform: translateY(10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } @keyframes bounce { 0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8,0,1,1); } 50% { transform: none; animation-timing-function: cubic-bezier(0,0,0.2,1); } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } } @keyframes shake { 10%, 90% { transform: translate3d(-1px, 0, 0); } 20%, 80% { transform: translate3d(2px, 0, 0); } 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } 40%, 60% { transform: translate3d(4px, 0, 0); } } @keyframes zoom-in { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes zoom-out { from { transform: scale(1); opacity: 1; } to { transform: scale(0); opacity: 0; } } `; const modeStyles = darkVars ? `\n\n[data-sxo-mode="dark"] {\n${darkVars}\n}` : ''; return `${reset}\n:root {\n${body}\n}${modeStyles}\n${animations}`; } reset() { this.cache.clear(); this.generatedClasses.clear(); } private formatCss( className: string, result: string | Record, variantSuffix = '', parentPrefix = '', mediaQuery = '', important = false, ): string { const baseSelector = `.${this.escapeClassName(className)}${variantSuffix}`; let selector = ''; if (parentPrefix) { if (parentPrefix.includes('&')) { selector = parentPrefix.replace(/&/g, baseSelector); } else { selector = `${parentPrefix} ${baseSelector}`; } } else { selector = baseSelector; } const processResult = (sel: string, res: string | Record): string => { if (typeof res === 'string') { const value = important ? `${res.replace(/;$/, '')} !important;` : res; return `${sel} { ${value} }`; } let css = ''; const rules: string[] = []; const nested: string[] = []; for (const [p, v] of Object.entries(res)) { if (typeof v === 'object') { const nestedSelector = p.replace(/&/g, sel); nested.push(processResult(nestedSelector, v)); } else { const value = important ? `${v} !important` : v; rules.push(`${p}: ${value};`); } } if (rules.length > 0) { css += `${sel} { ${rules.join(' ')} }`; } if (nested.length > 0) { css += (css ? '\n' : '') + nested.join('\n'); } return css; }; let css = processResult(selector, result); if (mediaQuery) { const mq = mediaQuery.startsWith('@media') ? mediaQuery : `@media ${mediaQuery}`; css = `${mq} {\n${css .split('\n') .map((line) => ` ${line}`) .join('\n')}\n}`; } return css; } private escapeClassName(className: string): string { return className.replace(/[!#$%&'()*+,./:;<=>?@[\]^`{|}~]/g, '\\$&'); } }