diff --git a/src/tasty/parser/ast.ts b/src/tasty/parser/ast.ts new file mode 100644 index 000000000..60fc15d9e --- /dev/null +++ b/src/tasty/parser/ast.ts @@ -0,0 +1,67 @@ +import { RawStyleToken } from './tokenizer'; + +export type StyleToken = RawStyleToken & { + children?: StyleToken[]; +}; + +const COLOR_FUNCTIONS = ['rgb', 'rgba', 'hsl', 'hsla', 'lch', 'oklch']; + +/** + * Create an AST from a flat array of style tokens. + */ +export function createAST( + tokens: StyleToken[], + startIndex = 0, +): [StyleToken[], number] { + const ast: StyleToken[] = []; + + for (let i: number = startIndex; i < tokens.length; i++) { + let token = tokens[i]; + + switch (token.type) { + case 'property': + const propertyName = `--${token.value.slice(1)}`; + + token.type = 'func'; + token.value = 'var'; + token.children = [ + { + type: 'propertyName', + value: propertyName, + }, + ]; + break; + case 'func': + if (COLOR_FUNCTIONS.includes(token.value)) { + token.type = 'color'; + + do { + i++; + token.value += tokens[i].value; + } while (tokens[i] && tokens[i].value !== ')'); + } else { + [token.children, i] = createAST(tokens, i + 2); + } + break; + case 'bracket': + if (token.value === '(') { + token.type = 'func'; + token.value = 'calc'; + [token.children, i] = createAST(tokens, i + 1); + } else if (token.value === ')') { + return [ast, i + 1]; + } + break; + default: + break; + } + + if (token.type !== 'space') { + ast.push(token); + } + + if (i === -1) break; + } + + return [ast, -1]; +} diff --git a/src/tasty/parser/index.ts b/src/tasty/parser/index.ts new file mode 100644 index 000000000..c8f802fab --- /dev/null +++ b/src/tasty/parser/index.ts @@ -0,0 +1,36 @@ +import { tokenize } from './tokenizer'; +import { renderStyleTokens, CustomUnitMap } from './renderer'; +import { createAST, StyleToken } from './ast'; + +interface StyleParserProps { + units: CustomUnitMap; +} + +export function CreateStyleParser({ units }: StyleParserProps) { + return { + parse(value) { + return createAST(tokenize(value)); + }, + render(tokens) { + return renderStyleTokens(tokens, { units }); + }, + excludeMods(tokens: StyleToken[], allMods: string[]) { + const mods: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.type === 'text') { + const mod = token.value; + + if (allMods.includes(mod)) { + mods.push(mod); + tokens.splice(i--, 1); + } + } + } + + return mods; + }, + }; +} diff --git a/src/tasty/parser/renderer.ts b/src/tasty/parser/renderer.ts new file mode 100644 index 000000000..ebe2749e6 --- /dev/null +++ b/src/tasty/parser/renderer.ts @@ -0,0 +1,53 @@ +import { StyleToken } from './ast'; + +export type CustomUnitMap = Record string)>; + +export function renderCustomUnit(token: StyleToken, units?: CustomUnitMap) { + units = units || {}; + + const converter = token.unit ? units[token.unit] : undefined; + + if (!converter) { + return token.value; + } + + if (typeof converter === 'function') { + return converter(token.value); + } + + if (token.value === '1') { + return converter; + } + + return `calc(${token.amount} * ${converter})`; +} + +const INSTANT_VALUES = [')', ',']; + +/** + * Render a style tokens to a string. + */ +export function renderStyleTokens( + tokens: StyleToken[], + { units }: { units?: CustomUnitMap } = {}, +) { + let result = ''; + + tokens.forEach((token) => { + if (INSTANT_VALUES.includes(token.value)) { + result += token.value; + } else if (token.type === 'func') { + result += token.children + ? `${token.value}(${renderStyleTokens(token.children, units)})` + : ''; + } else if (token.type === 'value') { + result += renderCustomUnit(token, units); + } else if (token.type === 'property') { + result += `var(--${token.value.slice(1)})`; + } else { + result += token.value; + } + }); + + return result; +} diff --git a/src/tasty/parser/tokenizer.ts b/src/tasty/parser/tokenizer.ts new file mode 100644 index 000000000..1fc2ad967 --- /dev/null +++ b/src/tasty/parser/tokenizer.ts @@ -0,0 +1,78 @@ +export type RawStyleToken = { + value: string; + type: typeof TOKEN_MAP[number]; + negative?: boolean; + amount?: number; + unit?: string; +}; + +const CACHE = new Map(); +const MAX_CACHE = 1000; +const REGEXP = + /((|-)([0-9]+|[0-9]+\.[0-9]+|\.[0-9]+)([a-z]+|%|))|([a-z][a-z0-9-]*(?=\())|([a-z][a-z0-9-]*)|(#[a-z][a-z0-9-]{2,}|#[a-f0-9]{3}|[a-f0-9]{6})|(@[a-z][a-z0-9-]*)|(--[a-z][a-z0-9-]*)|([+*/-])|([()])|("[^"]*"|'[^']*')|(,)|(\\|\|)|(\s+)/gi; +const TOKEN_MAP = [ + '', + 'value', + 'sign', + 'number', + 'unit', + 'func', + 'mod', + 'color', + 'property', + 'propertyName', + 'operator', + 'bracket', + 'text', + 'comma', + 'delimiter', + 'space', +]; + +/** + * Tokenize a style value into a flat array of tokens. + */ +export function tokenize(value: string): RawStyleToken[] { + value = value.trim(); + + if (!value) { + return []; + } + + if (CACHE.size > MAX_CACHE) { + CACHE.clear(); + } + + let tokens: RawStyleToken[] = []; + + if (!CACHE.has(value)) { + REGEXP.lastIndex = 0; + + let rawToken; + + while ((rawToken = REGEXP.exec(value))) { + const token: RawStyleToken = rawToken[1] + ? { + type: 'value', + value: rawToken[1], + negative: !!rawToken[2], + amount: parseFloat(rawToken[3]), + unit: rawToken[4], + } + : { + type: TOKEN_MAP[rawToken.indexOf(rawToken[0], 1)], + value: rawToken[0], + }; + + if (token.type != 'space') { + tokens.push(token); + } + } + + CACHE.set(value, tokens); + } + + const cachedTokens = CACHE.get(value); + + return cachedTokens ? cachedTokens : []; +}