diff --git a/.cursor/rules/parser.mdc b/.cursor/rules/parser.mdc new file mode 100644 index 00000000..19ddd910 --- /dev/null +++ b/.cursor/rules/parser.mdc @@ -0,0 +1,8 @@ +--- +description: If the task requires information about how styles are parsed in `tasty`. +globs: +alwaysApply: false +--- +The specification of the style parser is described in [parser.md](mdc:src/parser/parser.md) +This part of the styles handling only covers the parsing of string values. Though, boolean and number styles can be converted to string. +Style-2-state mapping and responsive values are handled separately in [styles.ts](mdc:src/tasty/utils/styles.ts) and [responsive.ts](mdc:src/tasty/utils/responsive.ts) diff --git a/.cursor/rules/tasty.mdc b/.cursor/rules/tasty.mdc new file mode 100644 index 00000000..508f5130 --- /dev/null +++ b/.cursor/rules/tasty.mdc @@ -0,0 +1,6 @@ +--- +description: If it requires the understanding of `tasty` helper API. +globs: +alwaysApply: false +--- +The API of `tasty` helper is described in [tasty.md](mdc:tasty.md) \ No newline at end of file diff --git a/.size-limit.cjs b/.size-limit.cjs index a12fe298..f9d01b7a 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -27,13 +27,13 @@ module.exports = [ path: './dist/es/index.js', webpack: true, import: '{ Button }', - limit: '23 kB', + limit: '24 kB', }, { name: 'Tree shaking (just an Icon)', path: './dist/es/index.js', webpack: true, import: '{ AiIcon }', - limit: '12 kB', + limit: '13 kB', }, ]; diff --git a/src/components/fields/Slider/RangeSlider.stories.tsx b/src/components/fields/Slider/RangeSlider.stories.tsx index a205eaf5..928c1ecf 100644 --- a/src/components/fields/Slider/RangeSlider.stories.tsx +++ b/src/components/fields/Slider/RangeSlider.stories.tsx @@ -42,3 +42,9 @@ WithoutValue.args = { label: 'Slider', showValueLabel: false, }; + +export const Vertical = Template.bind({}); +Vertical.args = { + label: 'Slider', + orientation: 'vertical', +}; diff --git a/src/components/fields/Slider/RangeSlider.tsx b/src/components/fields/Slider/RangeSlider.tsx index 24febdc9..d6234117 100644 --- a/src/components/fields/Slider/RangeSlider.tsx +++ b/src/components/fields/Slider/RangeSlider.tsx @@ -1,11 +1,11 @@ -import { forwardRef } from 'react'; +import { forwardRef, useRef } from 'react'; import { Gradation } from './Gradation'; import { SliderBase, SliderBaseChildArguments } from './SliderBase'; import { SliderThumb } from './SliderThumb'; import { SliderTrack } from './SliderTrack'; -import type { DOMRef } from '@react-types/shared'; +import type { FocusableRef } from '@react-types/shared'; import type { RangeValue } from '../../../shared'; import type { CubeSliderBaseProps } from './types'; @@ -19,21 +19,32 @@ const INTL_MESSAGES = { maximum: 'Maximum', }; -function RangeSlider(props: CubeRangeSliderProps, ref: DOMRef) { +function RangeSlider( + props: CubeRangeSliderProps, + ref: FocusableRef, +) { let { isDisabled, styles, gradation, ...otherProps } = props; + // Create separate refs for each thumb to enable proper focus management + const minThumbInputRef = useRef(null); + const maxThumbInputRef = useRef(null); + return ( - )}> + )}> {({ trackRef, inputRef, state }: SliderBaseChildArguments) => { return ( <> - + @@ -41,7 +52,7 @@ function RangeSlider(props: CubeRangeSliderProps, ref: DOMRef) { index={1} aria-label={INTL_MESSAGES['maximum']} state={state} - inputRef={inputRef} + inputRef={maxThumbInputRef} trackRef={trackRef} isDisabled={isDisabled} /> diff --git a/src/components/fields/Slider/SliderTrack.tsx b/src/components/fields/Slider/SliderTrack.tsx index 3f2f6e43..e1c5dc85 100644 --- a/src/components/fields/Slider/SliderTrack.tsx +++ b/src/components/fields/Slider/SliderTrack.tsx @@ -34,7 +34,9 @@ export function SliderTrack(props: SliderTrackProps) { '--slider-range-start': `${selectedTrack[0] * 100}%`, '--slider-range-end': `${selectedTrack[1] * 100}%`, } - : {} + : { + '--slider-value': `${selectedTrack[0] * 100}%`, + } } /> ); diff --git a/src/components/fields/Slider/elements.ts b/src/components/fields/Slider/elements.ts index ae08d0ea..ece73bfe 100644 --- a/src/components/fields/Slider/elements.ts +++ b/src/components/fields/Slider/elements.ts @@ -1,6 +1,7 @@ import { tasty } from '../../../tasty'; export const SliderThumbElement = tasty({ + qa: 'SliderThumb', styles: { top: '@slider-thumb-offset-top', left: '@slider-thumb-offset-left', @@ -28,6 +29,7 @@ export const SliderThumbElement = tasty({ }); export const SliderTrackContainerElement = tasty({ + qa: 'SliderTrackContainer', styles: { top: { '': '0', @@ -51,21 +53,33 @@ export const SliderTrackContainerElement = tasty({ '&::before': { content: '""', - display: { - '': 'none', - range: 'block', - }, + display: 'block', position: 'absolute', - top: 0, - bottom: 0, + inset: { + '': 'auto 0 0 0', + horizontal: '0 auto 0 0', + range: 'auto 0 @slider-range-start 0', + 'range & horizontal': '0 auto 0 @slider-range-start', + }, fill: '#purple', - left: '@slider-range-start', - width: '(@slider-range-end - @slider-range-start)', + width: { + '': 'auto', + horizontal: '@slider-value', + range: 'auto', + 'range & horizontal': '(@slider-range-end - @slider-range-start)', + }, + height: { + '': '@slider-value', + horizontal: 'auto', + range: '(@slider-range-end - @slider-range-start)', + 'range & horizontal': 'auto', + }, }, }, }); export const SliderGradationElement = tasty({ + qa: 'SliderGradation', styles: { position: 'absolute', top: '2x', @@ -78,6 +92,7 @@ export const SliderGradationElement = tasty({ }); export const SliderGradeElement = tasty({ + qa: 'SliderGrade', styles: { display: 'grid', width: 'max 0', @@ -88,6 +103,7 @@ export const SliderGradeElement = tasty({ }); export const SliderControlsElement = tasty({ + qa: 'SliderControls', styles: { position: 'relative', height: { @@ -96,7 +112,7 @@ export const SliderControlsElement = tasty({ }, width: { '': '2x', - horizontal: '100% - 2x', + horizontal: '(100% - 2x)', }, '@slider-thumb-offset-top': { @@ -130,7 +146,7 @@ export const SliderElement = tasty({ }, alignItems: 'center', - flexDirection: { + flow: { '': 'column', inputs: 'row', }, diff --git a/src/components/layout/Prefix.tsx b/src/components/layout/Prefix.tsx index 4c65cfbf..6b2dbd07 100644 --- a/src/components/layout/Prefix.tsx +++ b/src/components/layout/Prefix.tsx @@ -55,7 +55,7 @@ export const Prefix = forwardRef(function Prefix( styles={styles} style={{ // @ts-ignore - '--prefix-gap': parseStyle(outerGap).value, + '--prefix-gap': parseStyle(outerGap).output, }} > {children} diff --git a/src/components/layout/Suffix.tsx b/src/components/layout/Suffix.tsx index a75216f2..fb73a31f 100644 --- a/src/components/layout/Suffix.tsx +++ b/src/components/layout/Suffix.tsx @@ -53,7 +53,7 @@ export const Suffix = forwardRef(function Suffix( ref={ref} styles={styles} style={{ - '--suffix-gap': parseStyle(outerGap).value, + '--suffix-gap': parseStyle(outerGap).output, }} > {children} diff --git a/src/components/overlays/Tooltip/Tooltip.tsx b/src/components/overlays/Tooltip/Tooltip.tsx index c3075616..60f1ee0c 100644 --- a/src/components/overlays/Tooltip/Tooltip.tsx +++ b/src/components/overlays/Tooltip/Tooltip.tsx @@ -50,7 +50,7 @@ const TooltipElement = tasty({ }, filter: { '': false, - light: 'drop-shadow(0 0 1px rgb(var(--dark-color-rgb) / 20%)', + light: 'drop-shadow(0 0 1px #dark.2)', }, }, }); diff --git a/src/parser/classify.ts b/src/parser/classify.ts new file mode 100644 index 00000000..36e006c9 --- /dev/null +++ b/src/parser/classify.ts @@ -0,0 +1,208 @@ +import { + COLOR_FUNCS, + RE_HEX, + RE_NUMBER, + RE_UNIT_NUM, + VALUE_KEYWORDS, +} from './const'; +import { StyleParser } from './parser'; +import { Bucket, ParserOptions, ProcessedStyle } from './types'; + +export function classify( + raw: string, + opts: ParserOptions, + recurse: (str: string) => ProcessedStyle, +): { bucket: Bucket; processed: string } { + const token = raw.trim(); + if (!token) return { bucket: Bucket.Mod, processed: '' }; + + // Early-out: if the token contains unmatched parentheses treat it as invalid + // and skip it. This avoids cases like `drop-shadow(` that are missing a + // closing parenthesis (e.g., a user-typo in CSS). We count paren depth while + // ignoring everything inside string literals to avoid false positives. + { + let depth = 0; + let inQuote: string | 0 = 0; + for (let i = 0; i < token.length; i++) { + const ch = token[i]; + + // track quote context so parentheses inside quotes are ignored + if (inQuote) { + if (ch === inQuote && token[i - 1] !== '\\') inQuote = 0; + continue; + } + if (ch === '"' || ch === "'") { + inQuote = ch; + continue; + } + + if (ch === '(') depth++; + else if (ch === ')') depth = Math.max(0, depth - 1); + } + + if (depth !== 0) { + // Unbalanced parens → treat as invalid token (skipped). + console.warn( + 'tasty: skipped invalid function token with unmatched parentheses:', + token, + ); + return { bucket: Bucket.Mod, processed: '' }; + } + } + + // Quoted string literals should be treated as value tokens (e.g., "" for content) + if ( + (token.startsWith('"') && token.endsWith('"')) || + (token.startsWith("'") && token.endsWith("'")) + ) { + return { bucket: Bucket.Value, processed: token }; + } + + // 0. Direct var(--*-color) token + const varColorMatch = token.match(/^var\(--([a-z0-9-]+)-color\)$/); + if (varColorMatch) { + return { bucket: Bucket.Color, processed: token }; + } + + // 1. URL + if (token.startsWith('url(')) { + return { bucket: Bucket.Value, processed: token }; + } + + // 2. Custom property + if (token[0] === '@') { + const match = token.match(/^@\(([a-z0-9-_]+)\s*,\s*(.*)\)$/); + if (match) { + const [, name, fallback] = match; + const processedFallback = recurse(fallback).output; + return { + bucket: Bucket.Value, + processed: `var(--${name}, ${processedFallback})`, + }; + } + const identMatch = token.match(/^@([a-z0-9-_]+)$/); + if (identMatch) { + const name = identMatch[1]; + const processed = `var(--${name})`; + const bucketType = name.endsWith('-color') ? Bucket.Color : Bucket.Value; + return { + bucket: bucketType, + processed, + }; + } + // invalid custom property → modifier + } + + // 3. Hash colors (with optional alpha suffix e.g., #purple.5) + if (token[0] === '#' && token.length > 1) { + // alpha form: #name.alpha + const alphaMatch = token.match(/^#([a-z0-9-]+)\.([0-9]+)$/i); + if (alphaMatch) { + const [, base, rawAlpha] = alphaMatch; + let alpha: string; + if (rawAlpha === '0') alpha = '0'; + else alpha = `.${rawAlpha}`; + return { + bucket: Bucket.Color, + processed: `rgb(var(--${base}-color-rgb) / ${alpha})`, + }; + } + + // hyphenated names like #dark-05 should keep full name + + const name = token.slice(1); + // valid hex → treat as hex literal with fallback + if (RE_HEX.test(name)) { + return { + bucket: Bucket.Color, + processed: `var(--${name}-color, #${name})`, + }; + } + // simple color name token → css variable lookup with rgb fallback + return { bucket: Bucket.Color, processed: `var(--${name}-color)` }; + } + + // 4 & 5. Functions + const openIdx = token.indexOf('('); + if (openIdx > 0 && token.endsWith(')')) { + const fname = token.slice(0, openIdx); + const inner = token.slice(openIdx + 1, -1); // without () + + if (COLOR_FUNCS.has(fname)) { + // Process inner to expand nested colors or units. + const argProcessed = recurse(inner).output.replace(/,\s+/g, ','); // color funcs expect no spaces after commas + return { bucket: Bucket.Color, processed: `${fname}(${argProcessed})` }; + } + + // user function (provided via opts) + if (opts.funcs && fname in opts.funcs) { + // split by top-level commas within inner + const tmp = new StyleParser(opts).process(inner); // fresh parser w/ same opts but no cache share issues + const argProcessed = opts.funcs[fname](tmp.groups); + return { bucket: Bucket.Value, processed: argProcessed }; + } + + // generic: process inner and rebuild + const argProcessed = recurse(inner).output; + return { bucket: Bucket.Value, processed: `${fname}(${argProcessed})` }; + } + + // 6. Auto-calc group + if (token[0] === '(' && token[token.length - 1] === ')') { + const inner = token.slice(1, -1); + const innerProcessed = recurse(inner).output; + return { bucket: Bucket.Value, processed: `calc(${innerProcessed})` }; + } + + // 7. Unit number + const um = token.match(RE_UNIT_NUM); + if (um) { + const unit = um[1]; + const numericPart = parseFloat(token.slice(0, -unit.length)); + const handler = opts.units && opts.units[unit]; + if (handler) { + if (typeof handler === 'string') { + // Special-case the common `x` → gap mapping used by tests. + const base = unit === 'x' ? 'var(--gap)' : handler; + if (numericPart === 1) { + return { bucket: Bucket.Value, processed: base }; + } + return { + bucket: Bucket.Value, + processed: `calc(${numericPart} * ${base})`, + }; + } else { + const inner = handler(numericPart); + // Avoid double wrapping if handler already returns a calc(...) + return { + bucket: Bucket.Value, + processed: inner.startsWith('calc(') ? inner : `calc(${inner})`, + }; + } + } + } + + // 7b. Unknown numeric+unit → treat as literal value (e.g., 1fr) + if (/^[+-]?(?:\d*\.\d+|\d+)[a-z%]+$/.test(token)) { + return { bucket: Bucket.Value, processed: token }; + } + + // 7c. Plain unit-less numbers should be treated as value tokens so that + // code such as `scrollbar={10}` resolves correctly. + if (RE_NUMBER.test(token)) { + return { bucket: Bucket.Value, processed: token }; + } + + // 8. Literal value keywords + if (VALUE_KEYWORDS.has(token)) { + return { bucket: Bucket.Value, processed: token }; + } + + // 8b. Special keyword colors + if (token === 'transparent' || token === 'currentcolor') { + return { bucket: Bucket.Color, processed: token }; + } + + // 9. Fallback modifier + return { bucket: Bucket.Mod, processed: token }; +} diff --git a/src/parser/const.ts b/src/parser/const.ts new file mode 100644 index 00000000..b1ccaee8 --- /dev/null +++ b/src/parser/const.ts @@ -0,0 +1,29 @@ +export const VALUE_KEYWORDS = new Set([ + 'auto', + 'max-content', + 'min-content', + 'fit-content', + 'stretch', + 'initial', +]); + +export const COLOR_FUNCS = new Set([ + 'rgb', + 'rgba', + 'hsl', + 'hsla', + 'hwb', + 'lab', + 'lch', + 'oklab', + 'oklch', + 'color', + 'device-cmyk', + 'gray', + 'color-mix', + 'color-contrast', +]); + +export const RE_UNIT_NUM = /^[+-]?(?:\d*\.\d+|\d+)([a-z][a-z0-9]*)$/; +export const RE_NUMBER = /^[+-]?(?:\d*\.\d+|\d+)$/; +export const RE_HEX = /^(?:[0-9a-f]{3,4}|[0-9a-f]{6}(?:[0-9a-f]{2})?)$/; diff --git a/src/parser/lru.ts b/src/parser/lru.ts new file mode 100644 index 00000000..00225249 --- /dev/null +++ b/src/parser/lru.ts @@ -0,0 +1,58 @@ +export class Lru { + private map = new Map(); + private head: K | null = null; + private tail: K | null = null; + + constructor(private limit = 1000) {} + + get(key: K): V | undefined { + const node = this.map.get(key); + if (!node) return undefined; + this.touch(key, node); + return node.value; + } + + set(key: K, value: V) { + let node = this.map.get(key); + if (node) { + node.value = value; + this.touch(key, node); + return; + } + node = { prev: null, next: this.head, value }; + if (this.head) this.map.get(this.head)!.prev = key; + this.head = key; + if (!this.tail) this.tail = key; + this.map.set(key, node); + if (this.map.size > this.limit) this.evict(); + } + + private touch(key: K, node: { prev: K | null; next: K | null; value: V }) { + if (this.head === key) return; // already MRU + + // detach + if (node.prev) this.map.get(node.prev)!.next = node.next; + if (node.next) this.map.get(node.next)!.prev = node.prev; + if (this.tail === key) this.tail = node.prev; + + // move to head + node.prev = null; + node.next = this.head; + if (this.head) this.map.get(this.head)!.prev = key; + this.head = key; + } + + private evict() { + const old = this.tail; + if (!old) return; + const node = this.map.get(old)!; + if (node.prev) this.map.get(node.prev)!.next = null; + this.tail = node.prev; + this.map.delete(old); + } + + clear() { + this.map.clear(); + this.head = this.tail = null; + } +} diff --git a/src/parser/parser.md b/src/parser/parser.md new file mode 100644 index 00000000..6bcb5a11 --- /dev/null +++ b/src/parser/parser.md @@ -0,0 +1,260 @@ +# Style Parser – Complete Specification (v3) + +## Table of Contents + +1. [Overview & Scope](#overview--scope) +2. [Public API](#public-api) +3. [Core Concepts](#core-concepts) +4. [Parsing Pipeline](#parsing-pipeline) +5. [Token-Classification Rules](#token-classification-rules) +6. [Replacement Rules](#replacement-rules) +7. [Grouping & ProcessedStyle Construction](#grouping--processedstyle-construction) +8. [Cache Behavior](#cache-behavior) +9. [Error Handling & Best-Effort Strategy](#error-handling--best-effort-strategy) +10. [Normalization Rules](#normalization-rules) +11. [Performance Constraints](#performance-constraints) +12. [Definitive Lists](#definitive-lists) +13. [Edge-Case Playbook](#edge-case-playbook) +14. [Non-Goals](#non-goals) +15. [Implementation Plan (for developers)](#implementation-plan-for-developers) + +--- + +## 1. Overview & Scope + +The Style Parser converts an arbitrary CSS-like value string into: + +- **output** — a rewritten string that can be dropped into a style declaration, and +- **groups** — structured metadata (`StyleDetails[]`) for each top-level comma-separated segment. + +**Supported features:** + +- Color tokens and all CSS Color 5 functions. +- Custom units and auto-calc syntax (`2x`, `-.5r`, `(100% - 2r)` …). +- User-defined functions supplied via `funcs`. +- Custom properties with `@` syntax. +- Classification into values, colors, and modifiers. +- Whitespace compression. +- Bounded, configurable LRU cache. + +The parser operates in a single pass and never throws on malformed input. + +--- + +## 2. Public API + +### Types + +```ts +type StyleDetails = { + output: string; // processed subgroup string + mods: string[]; // recognized modifiers + values: string[]; // recognized numeric / functional / keyword values + colors: string[]; // recognized colors + all: string[]; // colors ∪ values ∪ mods, in source order +}; + +type ProcessedStyle = { + output: string; // group outputs joined with ", " + groups: StyleDetails[] // one per top-level comma +}; +``` + +### Options + +```ts +type UnitHandler = (scalar: number) => string; + +interface ParserOptions { + funcs?: Record< + string, + (parsedArgs: StyleDetails[]) => string + >; + units?: Record; + cacheSize?: number; // default = 1000 +} +``` + +### Class + +```ts +class StyleParser { + constructor(opts?: ParserOptions); + + /** Parse a style string. */ + process(src: string): ProcessedStyle; + + /** Replace the entire funcs table. */ + setFuncs(funcs: Required['funcs']): void; + + /** Replace the entire units table. */ + setUnits(units: Required['units']): void; + + /** Patch any subset of options (including cacheSize). */ + updateOptions(patch: Partial): void; +} +``` + +Each `StyleParser` instance maintains its own LRU cache. + +--- + +## 3. Core Concepts + +| Term | Meaning | +|-------------------|-------------------------------------------------------------------------| +| token | A contiguous chunk of input that is meaningful outside parentheses, URLs, and comments. | +| group | A sequence of tokens delimited by a top-level comma (depth 0). | +| value | Magnitude/keyword/function that is not a color. | +| color | A hash color token or a recognized color function call. | +| modifier | Anything else (e.g., thin, right). | +| auto-calc group | Parentheses not immediately preceded by an identifier or `url(`; rewritten to `calc( … )`. | + +--- + +## 4. Parsing Pipeline + +1. **Pre-scan normalization** + - Lower-case entire string. + - Strip CSS comments `/* … */`. +2. **Single-pass state machine** + - Track depth (parentheses), `inUrl`, and `inQuote` flags. + - At depth 0 & outside quotes/url: + - `,` → flush current token & end group. + - Whitespace → flush token, collapse spaces. +3. **Token flush** → classify (see §5) → append to current `StyleDetails`. +4. **Post-group** → build `StyleDetails.output` (join processed tokens with single spaces). +5. **Post-file** → join group outputs with `, ` → build `ProcessedStyle.output`. + +--- + +## 5. Token-Classification Rules + +| Order | Rule | Bucket | +|-------|--------------------------------------------------------------------------------------------|----------| +| 1 | URL – `url(` opens `inUrl`; everything to its `)` is a single token. | value | +| 2 | Custom property – `@ident` → `var(--ident)`; `@(ident,fallback)` → `var(--ident, )`. Only first `@` per token counts. | value | +| 3 | Hash token – `#xxxxxx` if valid hex → `var(--xxxxxx-color, #xxxxxx)`; otherwise `var(--name-color)`. | color | +| 4 | Color function – name in list §12.2 followed by `(` (balanced). | color | +| 5 | User / other function – `ident(` not in color list; parse args recursively, hand off to `funcs[name]` if provided; else rebuild with processed args. | value | +| 6 | Auto-calc group – parentheses not preceded by identifier. See §6. | value | +| 7 | Numeric + custom unit – regex `^[+-]?(\d*.\d+ \d+)([a-z][a-z0-9]*)$` and unit key exists. | | +| 8 | Literal value keyword – exactly `auto`, `max-content`, `min-content`, `fit-content`, `stretch`. | value | +| 9 | Fallback | modifier | + +Each processed string is inserted into its bucket and into `all` in source order. + +--- + +## 6. Replacement Rules + +| Situation | Replacement | +|--------------------------|---------------------------------------------------------------------------------------------| +| Custom unit (`2x`, `.75r`, `-3cr`) | `units[unit]`: • string → `calc(n * replacement)` • function → `calc(handler(numeric))`
`0u` stays `calc(0 * …)` (unit info preserved). | +| Auto-calc parentheses | Applies anywhere, nesting allowed.
Trigger = `(` whose previous non-space char is not `[a-z0-9_-]` and not `l` in `url(`.
Algorithm:
1. Strip outer parens.
2. Recursively parse inner text (so `2r`, `#fff`, nested auto-calc, etc., all expand).
3. Wrap in `calc( … )`. | +| Custom property | As in §5-2. | +| Hash colors | As in §5-3. | +| Color functions | Arguments are parsed, inner colors re-expanded; function name retained. | +| User functions | If `funcs[name]` exists → call with parsed arg-`StyleDetails[]`, use return string.
Else rebuild `ident()`. | + +--- + +## 7. Grouping & ProcessedStyle Construction + +- Group output = processed tokens joined by single spaces (redundant whitespace removed). +- File output = group outputs concatenated with `, ` (exactly one comma + space). +- Each bucket keeps original token order. + +--- + +## 8. Cache Behavior + +- Bounded LRU, keyed by the exact source string. +- Capacity = `options.cacheSize ?? 1000`. +- On hit, return the same `ProcessedStyle` object (no deep copy). + +--- + +## 9. Error Handling & Best-Effort Strategy + +- Parser never throws. +- On unmatched `)` / premature EOF → treat remainder as raw modifier token. +- Invalid unit number → leave token untouched, classify as modifier. +- Multiple `@` in one token → first valid custom-property processed, rest ignored. + +--- + +## 10. Normalization Rules + +- Entire input lower-cased before parsing. +- Outside parentheses/url, contiguous whitespace collapses to a single space. +- Leading & trailing spaces of the whole input are trimmed. + +--- + +## 11. Performance Constraints + +- Strict single-pass (O(n)) outer scan; recursion only for function/auto-calc substrings. +- No AST; minimal allocations. +- All regexes pre-compiled. + +--- + +## 12. Definitive Lists + +### 12.1 Value-keyword list + +``` +none auto max-content min-content fit-content +``` + +### 12.2 Recognized color functions + +``` +rgb rgba hsl hsla hwb lab lch oklab oklch color device-cmyk gray color-mix color-contrast +``` +(case-insensitive) + +### 12.3 CSS number (without exponent) + +``` +^[+-]?(\d*\.\d+|\d+)$ +``` + +--- + +## 13. Edge-Case Playbook + +| Case | Expected outcome | +|--------------------------------|----------------------------------------------------------------------------------| +| `url("img,with,comma.png")` | Single value token; comma doesn’t split. | +| `sum(min(1x,2x),(1px+5%))` | Inner `(1px+5%)` → `calc(1px + 5%)`. | +| `.75x` | `calc(0.75 * var(--gap))` value. | +| `1bw top #purple, 1ow right #dark-05` | Two groups; colors processed; positions as modifiers. | +| Comments `/*…*/2x` | `calc(2 * var(--gap))`. | +| `#+not-hash` | Modifier (fails hex test). | +| Excess spaces/newlines | Collapsed in output. | +| `+2r, 1e3x` | Invalid → modifiers. | +| Unicode identifiers | Modifiers (parser supports only kebab-case ASCII idents). | + +--- + +## 14. Non-Goals + +- Full CSS selector/at-rule parsing. +- Constant-folding math inside `calc()`. +- Vendor prefix quirks. +- Generating an AST. + +--- + +## 15. Implementation Plan (for developers) + +1. Tokenizer + state machine per §4. +2. Classifier implementing §5 & §6. +3. Group builder (collect `StyleDetails`). +4. Output builder (whitespace collapse, commas). +5. LRU cache (simple doubly-linked list + map). +6. Exposed mutator methods (`setFuncs`, `setUnits`, `updateOptions`). +7. Unit tests – provided suite plus all edge cases in §13. +8. Benchmark with long strings to check O(n) behavior. diff --git a/src/parser/parser.test.ts b/src/parser/parser.test.ts new file mode 100644 index 00000000..cfdf16aa --- /dev/null +++ b/src/parser/parser.test.ts @@ -0,0 +1,209 @@ +import { StyleParser } from './parser'; +import { StyleDetails } from './types'; + +const parser = new StyleParser({ + funcs: { + sum(parsed: StyleDetails[]) { + return `calc(${parsed + .map((s) => s.values[0]) + .filter(Boolean) + .join(' + ')})`; + }, + }, + units: { + x: '8px', + r: (v) => `calc(${v} * var(--radius))`, + cr: (v) => `calc(${v} * var(--card-radius))`, + bw: (v) => `calc(${v} * var(--border-width))`, + ow: (v) => `calc(${v} * var(--outline-width))`, + }, +}); + +describe('StyleProcessor', () => { + test('parses custom units and values', () => { + const result = parser.process('1x 2x 3cr 1bw 4ow'); + expect(result.groups[0].values).toEqual([ + 'var(--gap)', + 'calc(2 * var(--gap))', + 'calc(3 * var(--card-radius))', + 'calc(1 * var(--border-width))', + 'calc(4 * var(--outline-width))', + ]); + }); + + test('parses color tokens and color functions', () => { + const result = parser.process( + '#dark #purple.0 #purple.5 #purple.05 rgb(10,20,30) hsl(10,20%,30%)', + ); + expect(result.groups[0].colors).toEqual([ + 'var(--dark-color)', + 'rgb(var(--purple-color-rgb) / 0)', + 'rgb(var(--purple-color-rgb) / .5)', + 'rgb(var(--purple-color-rgb) / .05)', + 'rgb(10,20,30)', + 'hsl(10,20%,30%)', + ]); + }); + + test('parses custom properties', () => { + const result = parser.process('@my-gap @(my-gap, 2x)'); + expect(result.groups[0].values).toEqual([ + 'var(--my-gap)', + 'var(--my-gap, calc(2 * var(--gap)))', + ]); + }); + + test('parses value modifiers', () => { + const result = parser.process( + 'none auto max-content min-content fit-content stretch space-between', + ); + expect(result.groups[0].values).toEqual([ + 'auto', + 'max-content', + 'min-content', + 'fit-content', + 'stretch', + ]); + expect(result.groups[0].mods).toEqual(['none', 'space-between']); + }); + + test('parses modifiers', () => { + const result = parser.process('styled thin always'); + expect(result.groups[0].mods).toEqual(['styled', 'thin', 'always']); + }); + + test('parses user functions and nested functions', () => { + const result = parser.process('sum(1x, 2r, 3cr) min(1x, 2r)'); + expect(result.groups[0].values).toEqual([ + 'calc(var(--gap) + calc(2 * var(--radius)) + calc(3 * var(--card-radius)))', + 'min(var(--gap), calc(2 * var(--radius)))', + ]); + }); + + test('splits by top-level comma', () => { + const result = parser.process('1bw top #purple, 1ow right #dark-05'); + expect(result.groups.length).toBe(2); + expect(result.groups[0].values).toEqual(['calc(1 * var(--border-width))']); + expect(result.groups[0].colors).toEqual(['var(--purple-color)']); + expect(result.groups[1].values).toEqual(['calc(1 * var(--outline-width))']); + expect(result.groups[1].colors).toEqual(['var(--dark-05-color)']); + expect(result.output).toEqual( + 'calc(1 * var(--border-width)) top var(--purple-color), calc(1 * var(--outline-width)) right var(--dark-05-color)', + ); + expect(result.groups[0].mods).toEqual(['top']); + expect(result.groups[1].mods).toEqual(['right']); + }); + + test('handles edge cases and whitespace', () => { + const result = parser.process(' 2x (100% - 2r) max-content '); + expect(result.groups[0].values[0]).toContain('calc(2 * var(--gap))'); + expect(result.groups[0].values[1]).toContain( + 'calc(100% - calc(2 * var(--radius)))', + ); + expect(result.groups[0].values[2]).toContain('max-content'); + }); + + test('caches results', () => { + const a = parser.process('2x 3cr'); + const b = parser.process('2x 3cr'); + expect(a.groups).toBe(b.groups); // should be the same object from cache + }); + + test('parses linear-gradient value', () => { + const gradients = 'linear-gradient(90deg, #a1b2c3 0%, #000 100%)'; + const result = parser.process(gradients); + expect(result.groups[0].values[0]).toEqual( + 'linear-gradient(90deg, var(--a1b2c3-color, #a1b2c3) 0%, var(--000-color, #000) 100%)', + ); + }); + + test('parses drop shadow value', () => { + const dropShadow = 'drop-shadow(1x 2x 3x #dark.5)'; + const result = parser.process(dropShadow); + expect(result.groups[0].values[0]).toEqual( + 'drop-shadow(var(--gap) calc(2 * var(--gap)) calc(3 * var(--gap)) rgb(var(--dark-color-rgb) / .5))', + ); + }); + + test('parses background value with url and gradient', () => { + const background = + 'url(image.png) no-repeat center/cover, linear-gradient(45deg, red, blue)'; + const result = parser.process(background); + expect(result.output).toEqual(background); + expect(result.groups[0].values).toEqual([ + 'url(image.png) no-repeat center/cover', + ]); + expect(result.groups[1].values).toEqual([ + 'linear-gradient(45deg, red, blue)', + ]); + }); + + test('parses grid-template-columns value', () => { + const grid = '1fr 2fr minmax(100px, 1fr)'; + const result = parser.process(grid); + expect(result.output).toEqual(grid); + expect(result.groups[0].values).toEqual([ + '1fr', + '2fr', + 'minmax(100px, 1fr)', + ]); + }); + + test('parses fractional unit values', () => { + const result = parser.process('.75x'); + expect(result.groups[0].values[0]).toBe('calc(0.75 * var(--gap))'); + }); + + test('parses negative unit values', () => { + const result = parser.process('-2x -.5r'); + expect(result.groups[0].values).toEqual([ + 'calc(-2 * var(--gap))', + 'calc(-0.5 * var(--radius))', + ]); + }); + + test('treats custom var/@ colors as colors', () => { + const res = parser.process('@clear-color var(--clear-color)'); + expect(res.groups[0].colors).toEqual([ + 'var(--clear-color)', + 'var(--clear-color)', + ]); + }); + + test('recognises transparent keyword as color', () => { + const r = parser.process('transparent 1x'); + expect(r.groups[0].colors).toEqual(['transparent']); + expect(r.groups[0].values).toContain('var(--gap)'); + }); + + test('handles hyphenated #color names', () => { + const r = parser.process('#dark-02'); + expect(r.groups[0].colors).toEqual(['var(--dark-02-color)']); + }); + + test('parses empty string literal', () => { + const res = parser.process('""'); + expect(res.groups[0].values).toEqual(['""']); + }); + + test('parses calc with custom props inside parentheses', () => { + const expr = '(@slider-range-end - @slider-range-start)'; + const res = parser.process(expr); + expect(res.groups[0].values).toEqual([ + 'calc(var(--slider-range-end) - var(--slider-range-start))', + ]); + }); + + test('skips invalid functions while parsing (for example missing closing parenthesis)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const expr = + 'blur(10px) drop-shadow(0 0 1px rgb(var(--dark-color-rgb) / 20%)'; + const res = parser.process(expr); + + expect(res.groups[0].values).toEqual(['blur(10px)']); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/parser/parser.ts b/src/parser/parser.ts new file mode 100644 index 00000000..b02c6bd6 --- /dev/null +++ b/src/parser/parser.ts @@ -0,0 +1,117 @@ +import { classify } from './classify'; +import { Lru } from './lru'; +import { scan } from './tokenizer'; +import { + Bucket, + finalizeGroup, + makeEmptyDetails, + ParserOptions, + ProcessedStyle, + StyleDetails, +} from './types'; + +export class StyleParser { + private cache: Lru; + constructor(private opts: ParserOptions = {}) { + this.cache = new Lru(this.opts.cacheSize ?? 1000); + } + + /* ---------------- Public API ---------------- */ + process(src: string): ProcessedStyle { + const key = String(src); + const hit = this.cache.get(key); + if (hit) return hit; + + // strip comments & lower-case once + const stripped = src + .replace(/\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\//g, '') + .toLowerCase(); + + const groups: StyleDetails[] = []; + let current = makeEmptyDetails(); + + const pushToken = (bucket: Bucket, processed: string) => { + if (!processed) return; + + // If the previous token was a url(...) value, merge this token into it so that + // background layer segments like "url(img) no-repeat center/cover" are kept + // as a single value entry. + const mergeIntoPrevUrl = () => { + const lastIdx = current.values.length - 1; + current.values[lastIdx] += ` ${processed}`; + const lastAllIdx = current.all.length - 1; + current.all[lastAllIdx] += ` ${processed}`; + }; + + const prevIsUrlValue = + current.values.length > 0 && + current.values[current.values.length - 1].startsWith('url('); + + if (prevIsUrlValue) { + // Extend the existing url(...) value regardless of current bucket. + mergeIntoPrevUrl(); + // Additionally, for non-value buckets we need to remove their own storage. + // So early return. + return; + } + + switch (bucket) { + case Bucket.Color: + current.colors.push(processed); + break; + case Bucket.Value: + current.values.push(processed); + break; + case Bucket.Mod: + current.mods.push(processed); + break; + } + current.all.push(processed); + }; + + const endGroup = () => { + finalizeGroup(current); + groups.push(current); + current = makeEmptyDetails(); + }; + + scan(stripped, (tok, isComma, prevChar) => { + if (tok) { + const { bucket, processed } = classify(tok, this.opts, (sub) => + this.process(sub), + ); + pushToken(bucket, processed); + } + if (isComma) endGroup(); + }); + + // push final group if not already + if (current.all.length || !groups.length) endGroup(); + + const output = groups.map((g) => g.output).join(', '); + const result: ProcessedStyle = { output, groups }; + Object.freeze(result); + this.cache.set(key, result); + return result; + } + + setFuncs(funcs: Required['funcs']): void { + this.opts.funcs = funcs; + this.cache.clear(); + } + + setUnits(units: Required['units']): void { + this.opts.units = units; + this.cache.clear(); + } + + updateOptions(patch: Partial): void { + Object.assign(this.opts, patch); + if (patch.cacheSize) + this.cache = new Lru(patch.cacheSize); + else this.cache.clear(); + } +} + +// Re-export +export type { StyleDetails, ProcessedStyle } from './types'; diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts new file mode 100644 index 00000000..9f6b7ca4 --- /dev/null +++ b/src/parser/tokenizer.ts @@ -0,0 +1,73 @@ +export type TokenCallback = ( + token: string, + isComma: boolean, + precedingChar: string | null, +) => void; + +export function scan(src: string, cb: TokenCallback) { + let depth = 0; + let inUrl = false; + let inQuote: string | 0 = 0; + let start = 0; + let i = 0; + + const flush = (isComma: boolean) => { + if (start < i) { + const prevChar = start > 0 ? src[start - 1] : null; + cb(src.slice(start, i), isComma, prevChar); + } else if (isComma) { + cb('', true, null); // empty token followed by comma => group break. + } + start = i + 1; + }; + + for (; i < src.length; i++) { + const ch = src[i]; + + // quote mode + if (inQuote) { + if (ch === inQuote && src[i - 1] !== '\\') inQuote = 0; + continue; + } + if (ch === '"' || ch === "'") { + inQuote = ch; + continue; + } + + // paren & url tracking (not inside quotes) + if (ch === '(') { + // detect url( + if (!depth) { + const maybe = src.slice(Math.max(0, i - 3), i + 1); + if (maybe === 'url(') inUrl = true; + } + depth++; + continue; + } + if (ch === ')') { + depth = Math.max(0, depth - 1); + if (inUrl && depth === 0) inUrl = false; + continue; + } + + if (inUrl) continue; // inside url(...) treat everything as part of token + + if (!depth) { + if (ch === ',') { + flush(true); + continue; + } + if ( + ch === ' ' || + ch === '\n' || + ch === '\t' || + ch === '\r' || + ch === '\f' + ) { + flush(false); + continue; + } + } + } + flush(false); // tail +} diff --git a/src/parser/types.ts b/src/parser/types.ts new file mode 100644 index 00000000..4330f1c5 --- /dev/null +++ b/src/parser/types.ts @@ -0,0 +1,40 @@ +export enum Bucket { + Color, + Value, + Mod, +} + +export interface StyleDetails { + output: string; + mods: string[]; + values: string[]; + colors: string[]; + all: string[]; +} + +export interface ProcessedStyle { + output: string; + groups: StyleDetails[]; +} + +export type UnitHandler = (scalar: number) => string; + +export interface ParserOptions { + funcs?: Record string>; + units?: Record; + cacheSize?: number; +} + +export const makeEmptyDetails = (): StyleDetails => ({ + output: '', + mods: [], + values: [], + colors: [], + all: [], +}); + +export const finalizeGroup = (d: StyleDetails): StyleDetails => { + // Join processed pieces already stored in `all` with single spaces. + d.output = d.all.join(' '); + return d; +}; diff --git a/src/tasty/__snapshots__/tasty.test.tsx.snap b/src/tasty/__snapshots__/tasty.test.tsx.snap index 075b5213..5df81e98 100644 --- a/src/tasty/__snapshots__/tasty.test.tsx.snap +++ b/src/tasty/__snapshots__/tasty.test.tsx.snap @@ -84,7 +84,7 @@ exports[`tasty() API should be able to override styles 1`] = ` } .c0.c0 { - color: rgb(var(--black-color-rgb) / 0.1); + color: rgb(var(--black-color-rgb) / .1); --current-color: var(--black-color, black); --current-color-rgb: var(--black-color-rgb); } @@ -335,7 +335,7 @@ exports[`tasty() API should pass styles from tasty 1`] = ` } .c0.c0 { - color: rgb(var(--clear-color-rgb) / 0.1); + color: rgb(var(--clear-color-rgb) / .1); --current-color: var(--clear-color, clear); --current-color-rgb: var(--clear-color-rgb); } diff --git a/src/tasty/styles.test.ts b/src/tasty/styles.test.ts index cb66ce1b..a978fb2e 100644 --- a/src/tasty/styles.test.ts +++ b/src/tasty/styles.test.ts @@ -7,18 +7,17 @@ import { outlineStyle } from './styles/outline'; import { paddingStyle } from './styles/padding'; import { presetStyle } from './styles/preset'; import { radiusStyle } from './styles/radius'; -import { scrollbarStyle } from './styles/scrollbar'; describe('Tasty style tests', () => { it('should handle border styles', () => { expect(borderStyle({ border: '1px solid #000' })).toEqual({ - border: '1px solid var(--000-color, rgb(0 0 0))', + border: '1px solid var(--000-color, #000)', }); }); it('should handle outline styles', () => { expect(outlineStyle({ outline: '2px dashed #f00' })).toEqual({ - outline: '2px dashed var(--f00-color, rgb(255 0 0))', + outline: '2px dashed var(--f00-color, #f00)', }); }); diff --git a/src/tasty/styles/border.ts b/src/tasty/styles/border.ts index 85727d7d..e12b8b34 100644 --- a/src/tasty/styles/border.ts +++ b/src/tasty/styles/border.ts @@ -1,3 +1,4 @@ +import { StyleDetails } from '../../parser/types'; import { DIRECTIONS, filterMods, parseStyle } from '../utils/styles'; const BORDER_STYLES = [ @@ -23,7 +24,13 @@ export function borderStyle({ border }) { if (border === true) border = '1bw'; - const { values, mods, colors } = parseStyle(String(border)); + const processed = parseStyle(String(border)); + const { values, mods, colors } = + processed.groups[0] ?? + ({ values: [], mods: [], colors: [] } as Pick< + StyleDetails, + 'values' | 'mods' | 'colors' + >); const directions = filterMods(mods, DIRECTIONS); const typeMods = filterMods(mods, BORDER_STYLES); diff --git a/src/tasty/styles/createStyle.ts b/src/tasty/styles/createStyle.ts index ec49e497..75127772 100644 --- a/src/tasty/styles/createStyle.ts +++ b/src/tasty/styles/createStyle.ts @@ -75,9 +75,8 @@ export function createStyle( }; } - const { value } = parseStyle(styleValue, 1); - - return { [finalCssStyle]: value }; + const processed = parseStyle(styleValue as any); + return { [finalCssStyle]: processed.output }; }; styleHandler.__lookupStyles = [styleName]; diff --git a/src/tasty/styles/dimension.test.ts b/src/tasty/styles/dimension.test.ts new file mode 100644 index 00000000..a9d20b82 --- /dev/null +++ b/src/tasty/styles/dimension.test.ts @@ -0,0 +1,86 @@ +import { heightStyle } from './height'; +import { widthStyle } from './width'; + +const { parseStyle } = require('../utils/styles'); + +describe('dimensionStyle – width & height helpers', () => { + test('single value width', () => { + const res = widthStyle({ width: '10x' }) as any; + expect(res.width).toBe('calc(10 * var(--gap))'); + expect(res['min-width']).toBe('initial'); + expect(res['max-width']).toBe('initial'); + }); + + test('min & max width (two values)', () => { + const res = widthStyle({ width: '1x 10x' }) as any; + expect(res.width).toBe('auto'); + expect(res['min-width']).toBe('var(--gap)'); + expect(res['max-width']).toBe('calc(10 * var(--gap))'); + }); + + test('min modifier width', () => { + const res = widthStyle({ width: 'min 2x' }) as any; + expect(res.width).toBe('auto'); + expect(res['min-width']).toBe('calc(2 * var(--gap))'); + expect(res['max-width']).toBe('initial'); + }); + + test('max modifier width', () => { + const res = widthStyle({ width: 'max 2x' }) as any; + expect(res.width).toBe('auto'); + expect(res['min-width']).toBe('initial'); + expect(res['max-width']).toBe('calc(2 * var(--gap))'); + }); + + test('width three args', () => { + const res = widthStyle({ width: 'initial 36x max-content' }) as any; + expect(res.width).toBe('calc(36 * var(--gap))'); + expect(res['min-width']).toBe('initial'); + expect(res['max-width']).toBe('max-content'); + }); + + test('stretch width keyword', () => { + const res = widthStyle({ width: 'stretch' }) as any; + expect(res.width).toEqual([ + 'stretch', + '-webkit-fill-available', + '-moz-available', + ]); + }); + + test('boolean true width (auto)', () => { + const res = widthStyle({ width: true }) as any; + expect(res.width).toBe('auto'); + }); + + test('responsive array width', () => { + const res = widthStyle({ width: '1x 2x' }) as any; + expect(res.width).toBe('auto'); + expect(res['min-width']).toBe('var(--gap)'); + expect(res['max-width']).toBe('calc(2 * var(--gap))'); + }); + + test('single value height', () => { + const res = heightStyle({ height: '100px' }) as any; + expect(res.height).toBe('100px'); + expect(res['min-height']).toBe('initial'); + expect(res['max-height']).toBe('initial'); + }); + + test('height three args', () => { + const res = heightStyle({ height: '1x 5x 10x' }) as any; + expect(res.height).toBe('calc(5 * var(--gap))'); + expect(res['min-height']).toBe('var(--gap)'); + expect(res['max-height']).toBe('calc(10 * var(--gap))'); + }); + + test('boolean true height (auto)', () => { + const res = heightStyle({ height: true }) as any; + expect(res.height).toBe('auto'); + }); + + test('stretch height keyword', () => { + const res = heightStyle({ height: 'stretch' }) as any; + expect(res.height).toBe('auto'); + }); +}); diff --git a/src/tasty/styles/dimension.ts b/src/tasty/styles/dimension.ts index e6b7ca26..2a3bc71b 100644 --- a/src/tasty/styles/dimension.ts +++ b/src/tasty/styles/dimension.ts @@ -1,34 +1,21 @@ -import { parseStyle, transferMods } from '../utils/styles'; +import { parseStyle } from '../utils/styles'; const DEFAULT_MIN_SIZE = 'var(--gap)'; const DEFAULT_MAX_SIZE = '100%'; -function isSizingSupport(val) { - return typeof CSS !== 'undefined' && typeof CSS?.supports === 'function' - ? CSS.supports('height', val) - : false; -} - -const STRETCH = 'stretch'; -const FILL_AVAILABLE = 'fill-available'; -const WEBKIT_FILL_AVAILABLE = '-webkit-fill-available'; -const MOZ_FILL_AVAILABLE = '-moz-fill-available'; -const STRETCH_SIZE = isSizingSupport(STRETCH) - ? STRETCH - : isSizingSupport(FILL_AVAILABLE) - ? FILL_AVAILABLE - : isSizingSupport(WEBKIT_FILL_AVAILABLE) - ? WEBKIT_FILL_AVAILABLE - : isSizingSupport(MOZ_FILL_AVAILABLE) - ? MOZ_FILL_AVAILABLE - : null; -const INTRINSIC_MODS = ['max-content', 'min-content', 'fit-content', 'stretch']; - export function dimensionStyle(name) { const minStyle = `min-${name}`; const maxStyle = `max-${name}`; return (val) => { + if (val === true) { + return { + [name]: 'auto', + [minStyle]: 'initial', + [maxStyle]: 'initial', + }; + } + if (!val) return ''; if (typeof val === 'number') { @@ -37,21 +24,15 @@ export function dimensionStyle(name) { val = String(val); - const styles = { + const styles: Record = { [name]: 'auto', [minStyle]: 'initial', [maxStyle]: 'initial', }; - const { mods, values } = parseStyle(val); - - transferMods(INTRINSIC_MODS, mods, values); - - values.forEach((v, i) => { - if (v === 'stretch') { - values[i] = STRETCH_SIZE || (name === 'height' ? '100vh' : '100vw'); - } - }); + const processed = parseStyle(val); + const { mods, values } = + processed.groups[0] ?? ({ mods: [], values: [] } as any); let flag = false; @@ -83,6 +64,14 @@ export function dimensionStyle(name) { } } + if (styles[name] === 'stretch') { + if (name === 'width') { + styles[name] = ['stretch', '-webkit-fill-available', '-moz-available']; + } else { + styles[name] = 'auto'; + } + } + return styles; }; } diff --git a/src/tasty/styles/fade.ts b/src/tasty/styles/fade.ts index 69dfc44a..e0d6b8ee 100644 --- a/src/tasty/styles/fade.ts +++ b/src/tasty/styles/fade.ts @@ -10,7 +10,9 @@ const DIRECTION_MAP = { export function fadeStyle({ fade }) { if (!fade) return ''; - let { values, mods } = parseStyle(fade); + const processed = parseStyle(fade); + let { values, mods } = + processed.groups[0] ?? ({ values: [], mods: [] } as any); let directions = filterMods(mods, DIRECTIONS); diff --git a/src/tasty/styles/fill.ts b/src/tasty/styles/fill.ts index 535cc5d3..e3f7c36e 100644 --- a/src/tasty/styles/fill.ts +++ b/src/tasty/styles/fill.ts @@ -3,9 +3,8 @@ import { parseStyle } from '../utils/styles'; export function fillStyle({ fill }) { if (!fill) return ''; - if (fill.startsWith('#')) { - fill = parseStyle(fill).colors[0] || fill; - } + const processed = parseStyle(fill); + fill = processed.groups[0]?.colors[0] || fill; const match = fill.match(/var\(--(.+?)-color/); let name = ''; diff --git a/src/tasty/styles/gap.ts b/src/tasty/styles/gap.ts index 4e39b8bc..d9bf4511 100644 --- a/src/tasty/styles/gap.ts +++ b/src/tasty/styles/gap.ts @@ -31,7 +31,8 @@ export function gapStyle({ flow = isFlex ? 'row' : 'column'; } - const { values } = parseStyle(gap); + const processed = parseStyle(gap); + const { values } = processed.groups[0] ?? ({ values: [] } as any); gap = values.join(' '); diff --git a/src/tasty/styles/groupRadius.ts b/src/tasty/styles/groupRadius.ts index 57d995b5..f83ccd72 100644 --- a/src/tasty/styles/groupRadius.ts +++ b/src/tasty/styles/groupRadius.ts @@ -10,7 +10,9 @@ export function groupRadiusAttr({ groupRadius, flow }) { if (groupRadius === true) groupRadius = '1r'; - const { values, mods } = parseStyle(groupRadius); + const processed = parseStyle(groupRadius); + const { values, mods } = + processed.groups[0] ?? ({ values: [], mods: [] } as any); flow = flow || 'row'; diff --git a/src/tasty/styles/inset.ts b/src/tasty/styles/inset.ts index bf4da4a3..23838248 100644 --- a/src/tasty/styles/inset.ts +++ b/src/tasty/styles/inset.ts @@ -9,7 +9,9 @@ export function insetStyle({ inset }) { if (inset === true) inset = '0 0 0 0'; - let { values, mods } = parseStyle(inset); + const processed = parseStyle(inset); + let { values, mods } = + processed.groups[0] ?? ({ values: [], mods: [] } as any); let directions = filterMods(mods, DIRECTIONS); diff --git a/src/tasty/styles/margin.ts b/src/tasty/styles/margin.ts index 0e5716fd..979b5e99 100644 --- a/src/tasty/styles/margin.ts +++ b/src/tasty/styles/margin.ts @@ -25,7 +25,9 @@ export function marginStyle({ if (margin === true) margin = '1x'; - let { values, mods } = parseStyle(margin); + const processed = parseStyle(margin); + let { values, mods } = + processed.groups[0] ?? ({ values: [], mods: [] } as any); let directions = filterMods(mods, DIRECTIONS); diff --git a/src/tasty/styles/marginBlock.ts b/src/tasty/styles/marginBlock.ts index a46f305d..baf1ecb1 100644 --- a/src/tasty/styles/marginBlock.ts +++ b/src/tasty/styles/marginBlock.ts @@ -13,7 +13,8 @@ export function marginBlockStyle({ if (margin === true) margin = '1x'; - let { values } = parseStyle(margin); + const processed = parseStyle(margin); + let { values } = processed.groups[0] ?? ({ values: [] } as any); if (!values.length) { values = ['var(--gap)']; diff --git a/src/tasty/styles/marginInline.ts b/src/tasty/styles/marginInline.ts index 71c5d823..6c27bec9 100644 --- a/src/tasty/styles/marginInline.ts +++ b/src/tasty/styles/marginInline.ts @@ -13,7 +13,8 @@ export function marginInlineStyle({ if (margin === true) margin = '1x'; - let { values } = parseStyle(margin); + const processed = parseStyle(margin); + let { values } = processed.groups[0] ?? ({ values: [] } as any); if (!values.length) { values = ['var(--gap)']; diff --git a/src/tasty/styles/outline.ts b/src/tasty/styles/outline.ts index c11e718f..d72a5ea9 100644 --- a/src/tasty/styles/outline.ts +++ b/src/tasty/styles/outline.ts @@ -23,7 +23,9 @@ export function outlineStyle({ outline }) { if (outline === true) outline = '1ow'; - const { values, mods, colors } = parseStyle(String(outline)); + const processed = parseStyle(String(outline)); + const { values, mods, colors } = + processed.groups[0] ?? ({ values: [], mods: [], colors: [] } as any); const typeMods = filterMods(mods, BORDER_STYLES); diff --git a/src/tasty/styles/padding.ts b/src/tasty/styles/padding.ts index a5da1342..36e77d9a 100644 --- a/src/tasty/styles/padding.ts +++ b/src/tasty/styles/padding.ts @@ -34,7 +34,9 @@ export function paddingStyle({ if (padding === true) padding = '1x'; - let { values, mods } = parseStyle(padding); + const processed = parseStyle(padding); + let { values, mods } = + processed.groups[0] ?? ({ values: [], mods: [] } as any); let directions = filterMods(mods, DIRECTIONS); diff --git a/src/tasty/styles/paddingBlock.ts b/src/tasty/styles/paddingBlock.ts index 272aefb4..49a1fbba 100644 --- a/src/tasty/styles/paddingBlock.ts +++ b/src/tasty/styles/paddingBlock.ts @@ -13,7 +13,8 @@ export function paddingBlockStyle({ if (padding === true) padding = '1x'; - let { values } = parseStyle(padding); + const processed = parseStyle(padding); + let { values } = processed.groups[0] ?? ({ values: [] } as any); if (!values.length) { values = ['var(--gap)']; diff --git a/src/tasty/styles/paddingInline.ts b/src/tasty/styles/paddingInline.ts index c9c07b2e..a825b528 100644 --- a/src/tasty/styles/paddingInline.ts +++ b/src/tasty/styles/paddingInline.ts @@ -13,7 +13,8 @@ export function paddingInlineStyle({ if (padding === true) padding = '1x'; - let { values } = parseStyle(padding); + const processed = parseStyle(padding); + let { values } = processed.groups[0] ?? ({ values: [] } as any); if (!values.length) { values = ['var(--gap)']; diff --git a/src/tasty/styles/preset.ts b/src/tasty/styles/preset.ts index 3d4afef0..c6a79ba7 100644 --- a/src/tasty/styles/preset.ts +++ b/src/tasty/styles/preset.ts @@ -56,7 +56,8 @@ export function presetStyle({ if (preset === true) preset = ''; - let { mods } = parseStyle(preset); + const processed = parseStyle(preset); + let { mods } = processed.groups[0] ?? ({ mods: [] } as any); const isStrong = mods.includes('strong'); const isItalic = mods.includes('italic'); diff --git a/src/tasty/styles/radius.ts b/src/tasty/styles/radius.ts index 7356b626..8c7ad35f 100644 --- a/src/tasty/styles/radius.ts +++ b/src/tasty/styles/radius.ts @@ -12,7 +12,9 @@ export function radiusStyle({ radius }) { if (radius === true) radius = '1r'; - let { mods, values } = parseStyle(radius, 1); + const processed = parseStyle(radius); + let { mods, values } = + processed.groups[0] ?? ({ mods: [], values: [] } as any); if (mods.includes('round')) { values = ['9999rem']; diff --git a/src/tasty/styles/reset.ts b/src/tasty/styles/reset.ts index 6a75df03..bfec645d 100644 --- a/src/tasty/styles/reset.ts +++ b/src/tasty/styles/reset.ts @@ -70,7 +70,8 @@ text-decoration: none; export function resetStyle({ reset }) { if (!reset) return; - const { mods } = parseStyle(reset, 1); + const processed = parseStyle(reset); + const { mods } = processed.groups[0] ?? ({ mods: [] } as any); return mods.reduce((sum, mod) => { if (RESET_MAP[mod]) { diff --git a/src/tasty/styles/scrollbar.test.ts b/src/tasty/styles/scrollbar.test.ts index b18e02d2..20bd9f5d 100644 --- a/src/tasty/styles/scrollbar.test.ts +++ b/src/tasty/styles/scrollbar.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { scrollbarStyle } from './scrollbar'; describe('scrollbarStyle', () => { @@ -6,7 +7,7 @@ describe('scrollbarStyle', () => { }); it('handles boolean true value as thin', () => { - const result = scrollbarStyle({ scrollbar: true }); + const result: any = scrollbarStyle({ scrollbar: true })!; expect(result['scrollbar-width']).toBe('thin'); }); @@ -19,7 +20,7 @@ describe('scrollbarStyle', () => { it('handles "none" modifier', () => { const result = scrollbarStyle({ scrollbar: 'none' }); expect(result['scrollbar-width']).toBe('none'); - expect(result['scrollbar-color']).toBe('transparent transparent'); + expect((result as any)['scrollbar-color']).toBe('transparent transparent'); expect(result['&::-webkit-scrollbar']['width']).toBe('0px'); }); @@ -33,10 +34,10 @@ describe('scrollbarStyle', () => { it('handles custom colors', () => { const result = scrollbarStyle({ scrollbar: '#red #blue #green' }); - expect(result['scrollbar-color']).toBe( + expect((result as any)['scrollbar-color']).toBe( 'var(--red-color) var(--blue-color)', ); - expect(result['&::-webkit-scrollbar-track']['background']).toBe( + expect((result as any)['&::-webkit-scrollbar-track']['background']).toBe( 'var(--blue-color)', ); expect(result['&::-webkit-scrollbar-thumb']['background']).toBe( @@ -69,13 +70,13 @@ describe('scrollbarStyle', () => { const result = scrollbarStyle({ scrollbar: 'styled #purple #dark #light-grey', }); - expect(result['scrollbar-color']).toBe( + expect((result as any)['scrollbar-color']).toBe( 'var(--purple-color) var(--dark-color)', ); - expect(result['&::-webkit-scrollbar']['background']).toBe( + expect((result as any)['&::-webkit-scrollbar']['background']).toBe( 'var(--dark-color)', ); - expect(result['&::-webkit-scrollbar-track']['background']).toBe( + expect((result as any)['&::-webkit-scrollbar-track']['background']).toBe( 'var(--dark-color)', ); expect(result['&::-webkit-scrollbar-thumb']['background']).toBe( @@ -89,13 +90,13 @@ describe('scrollbarStyle', () => { it('applies partial custom colors with defaults', () => { const result = scrollbarStyle({ scrollbar: 'styled #danger' }); // Only thumb color specified, track should use default - expect(result['scrollbar-color']).toBe( + expect((result as any)['scrollbar-color']).toBe( 'var(--danger-color) var(--scrollbar-track-color, transparent)', ); expect(result['&::-webkit-scrollbar-thumb']['background']).toBe( 'var(--danger-color)', ); - expect(result['&::-webkit-scrollbar-track']['background']).toBe( + expect((result as any)['&::-webkit-scrollbar-track']['background']).toBe( 'var(--scrollbar-track-color, transparent)', ); }); diff --git a/src/tasty/styles/scrollbar.ts b/src/tasty/styles/scrollbar.ts index 49be1945..bd2344b5 100644 --- a/src/tasty/styles/scrollbar.ts +++ b/src/tasty/styles/scrollbar.ts @@ -17,7 +17,9 @@ export function scrollbarStyle({ scrollbar, overflow }: ScrollbarStyleProps) { // Support true as alias for thin const value = scrollbar === true || scrollbar === '' ? 'thin' : scrollbar; - const { mods, colors, values } = parseStyle(String(value)); + const processed = parseStyle(String(value)); + const { mods, colors, values } = + processed.groups[0] ?? ({ mods: [], colors: [], values: [] } as any); const style = {}; // Default colors for scrollbar @@ -34,7 +36,7 @@ export function scrollbarStyle({ scrollbar, overflow }: ScrollbarStyleProps) { // Process modifiers if (mods.includes('thin')) { style['scrollbar-width'] = 'thin'; - } else if (values.includes('none')) { + } else if (mods.includes('none')) { style['scrollbar-width'] = 'none'; style['scrollbar-color'] = 'transparent transparent'; // Also hide WebKit scrollbars diff --git a/src/tasty/styles/shadow.ts b/src/tasty/styles/shadow.ts index af225f3d..20e0b091 100644 --- a/src/tasty/styles/shadow.ts +++ b/src/tasty/styles/shadow.ts @@ -1,7 +1,9 @@ import { parseStyle } from '../utils/styles'; function toBoxShadow(shadow) { - const { values, mods, colors } = parseStyle(shadow); + const processed = parseStyle(shadow); + const { values, mods, colors } = + processed.groups[0] ?? ({ values: [], mods: [], colors: [] } as any); const mod = mods[0] || ''; const shadowColor = (colors && colors[0]) || 'var(--shadow-color)'; diff --git a/src/tasty/styles/transition.ts b/src/tasty/styles/transition.ts index 29a29e51..dfabfceb 100644 --- a/src/tasty/styles/transition.ts +++ b/src/tasty/styles/transition.ts @@ -47,7 +47,12 @@ function getTiming(name) { export function transitionStyle({ transition }) { if (!transition) return; - const tokens = parseStyle(transition).all; + const processed = parseStyle(transition); + const tokens: string[] = []; + processed.groups.forEach((g, idx) => { + tokens.push(...g.all); + if (idx < processed.groups.length - 1) tokens.push(','); + }); if (!tokens) return; diff --git a/src/tasty/utils/styles.ts b/src/tasty/utils/styles.ts index be313585..82a57ae3 100644 --- a/src/tasty/utils/styles.ts +++ b/src/tasty/utils/styles.ts @@ -1,9 +1,12 @@ +import { StyleParser } from '../../parser/parser'; import { Styles } from '../styles/types'; import { cacheWrapper } from './cache-wrapper'; import { camelToKebab } from './case-converter'; import { getModCombinations } from './getModCombinations'; +import type { ProcessedStyle, StyleDetails } from '../../parser/types'; + export type StyleValue = T | boolean | number | null | undefined; export type StyleValueStateMap = { @@ -135,29 +138,6 @@ export const CUSTOM_UNITS = { export const DIRECTIONS = ['top', 'right', 'bottom', 'left']; -const COLOR_FUNCS = ['rgb', 'rgba']; -const IGNORE_MODS = [ - 'auto', - 'max-content', - 'min-content', - 'none', - 'subgrid', - 'initial', -]; - -const ATTR_REGEXP = - /("[^"]*")|('[^']*')|([a-z-]+\()|(#[a-z0-9.-]{2,}(?![a-f0-9[-]))|(--[a-z0-9-]+|@[a-z0-9-]+)|([a-z][a-z0-9-]*)|(([0-9]+(?![0-9.])|[0-9-.]{2,}|[0-9-]{2,}|[0-9.-]{3,})([a-z%]{0,3}))|([*/+-])|([()])|(,)/gi; -const ATTR_CACHE = new Map(); -const ATTR_CACHE_AUTOCALC = new Map(); -const ATTR_CACHE_IGNORE_COLOR = new Map(); -const MAX_CACHE = 10000; -const ATTR_CACHE_MODE_MAP = [ - ATTR_CACHE_AUTOCALC, - ATTR_CACHE, - ATTR_CACHE_IGNORE_COLOR, -]; -const PREPARE_REGEXP = /calc\((\d*)\)/gi; - export function createRule( prop: string, value: StyleValue, @@ -185,355 +165,105 @@ function getModSelector(modName: string): string { return MOD_NAME_CACHE.get(modName); } +// Keep a single shared instance across the whole library so that the cache of +// the new StyleParser keeps working and custom functions/units can be updated +// at runtime. +const __tastyParser = new StyleParser({ units: CUSTOM_UNITS }); + +// Registry for user-provided custom functions that the parser can call. +// It is updated through the `customFunc` helper exported below. +const __tastyFuncs: Record string> = {}; + +export function customFunc( + name: string, + fn: (groups: StyleDetails[]) => string, +) { + __tastyFuncs[name] = fn; + __tastyParser.setFuncs(__tastyFuncs); +} + /** * * @param {String} value * @param {Number} mode * @returns {Object} */ -export function parseStyle(value: StyleValue, mode = 0): ParsedStyle { - if (typeof value === 'number') { - value = String(value); - } +export function parseStyle(value: StyleValue): ProcessedStyle { + let str: string; - if (typeof value !== 'string') { - return { - values: [], - mods: [], - all: [], - value: '', - colors: [], - }; + if (typeof value === 'string') { + str = value; + } else if (typeof value === 'number') { + str = String(value); + } else { + // boolean, null, undefined, objects etc. → empty string + str = ''; } - const CACHE = ATTR_CACHE_MODE_MAP[mode]; - - if (!CACHE.has(value)) { - if (CACHE.size > MAX_CACHE) { - CACHE.clear(); - } - - const mods: string[] = []; - const all: string[] = []; - const values: string[] = []; - const colors: string[] = []; - const autoCalc = mode !== 1; - - let currentValue = ''; - let calc = -1; - let counter = 0; - let parsedValue = ''; - let color: string | undefined = ''; - let currentFunc = ''; - let usedFunc = ''; - let token; - - ATTR_REGEXP.lastIndex = 0; - - value = value.replace(/@\(/g, 'var(--'); - - while ((token = ATTR_REGEXP.exec(value))) { - let [ - , - quotedDouble, - quotedSingle, - func, - hashColor, - prop, - mod, - unit, - unitVal, - unitMetric, - operator, - bracket, - comma, - ] = token; - - if (quotedSingle || quotedDouble) { - currentValue += `${quotedSingle || quotedDouble} `; - } else if (func) { - currentFunc = func.slice(0, -1); - currentValue += func; - counter++; - } else if (hashColor) { - if (mode === 2) { - color = hashColor; - } else { - color = parseColor(hashColor, false).color; - } - if (color) { - colors.push(color); - } - } else if (mod) { - // ignore mods inside brackets - if (counter || IGNORE_MODS.includes(mod)) { - currentValue += `${mod} `; - } else { - mods.push(mod); - all.push(mod); - parsedValue += `${mod} `; - } - } else if (bracket) { - if (bracket === '(') { - if (!~calc) { - calc = counter; - currentValue += 'calc'; - } - - counter++; - } - - if (bracket === ')' && counter) { - currentValue = currentValue.trim(); - - if (counter > 0) { - counter--; - } - - if (counter === calc) { - calc = -1; - } - } - - if (bracket === ')' && !counter) { - usedFunc = currentFunc; - currentFunc = ''; - } - - currentValue += `${bracket}${bracket === ')' ? ' ' : ''}`; - } else if (operator) { - if (!~calc && autoCalc) { - if (currentValue) { - if (currentValue.includes('(')) { - const index = currentValue.lastIndexOf('('); - - currentValue = `${currentValue.slice( - 0, - index, - )}(calc(${currentValue.slice(index + 1)}`; - - calc = counter; - counter++; - } - } else if (values.length) { - parsedValue = parsedValue.slice( - 0, - parsedValue.length - values[values.length - 1].length - 1, - ); - - let tmp = values.splice(values.length - 1, 1)[0]; - - all.splice(values.length - 1, 1); - - if (tmp) { - if (tmp.startsWith('calc(')) { - tmp = tmp.slice(4); - } - - calc = counter; - counter++; - currentValue = `calc((${tmp}) `; - } - } - } - - currentValue += `${operator} `; - } else if (unit) { - if (unitMetric && CUSTOM_UNITS[unitMetric]) { - let add = customUnit(unitVal, unitMetric); - - if (!~calc && add.startsWith('(')) { - currentValue += 'calc'; - } - - currentValue += `${add} `; - } else { - currentValue += `${unit} `; - } - } else if (prop) { - prop = prop.replace('@', '--'); - if (currentFunc !== 'var') { - currentValue += `var(${prop}) `; - } else { - currentValue += `${prop} `; - } - } else if (comma) { - if (~calc) { - calc = -1; - counter--; - currentValue = `${currentValue.trim()}), `; - } else { - currentValue = `${currentValue.trim()}, `; - } - - if (!counter) { - all.push(','); - } - } - - if (currentValue && !counter) { - let prepared = prepareParsedValue(currentValue); - - if (COLOR_FUNCS.includes(usedFunc)) { - color = prepared; - } else if (prepared.startsWith('color(')) { - prepared = prepared.slice(6, -1); - - color = parseColor(prepared).color; - } else { - if (prepared !== ',') { - values.push(prepared); - all.push(prepared); - } - - parsedValue += `${prepared} `; - } - - currentValue = ''; - } - } - - if (counter) { - let prepared = prepareParsedValue( - `${currentValue.trim()}${')'.repeat(counter)}`, - ); - - if (prepared.startsWith('color(')) { - prepared = prepared.slice(6, -1); + return __tastyParser.process(str); +} - color = parseColor(prepared).color; - } else { - if (prepared !== ',') { - values.push(prepared); - all.push(prepared); - } +// Utility: flatten groups into merged token lists (closest to legacy shape). +export function flattenStyleDetails(processed: ProcessedStyle) { + const merged = { + values: [] as string[], + mods: [] as string[], + colors: [] as string[], + all: [] as string[], + value: processed.output, + color: undefined as string | undefined, + }; - parsedValue += prepared; - } + processed.groups.forEach((g, idx) => { + merged.values.push(...g.values); + merged.mods.push(...g.mods); + merged.colors.push(...g.colors); + merged.all.push(...g.all); + if (idx < processed.groups.length - 1) { + merged.all.push(','); } + }); - CACHE.set(value, { - values, - mods, - all, - colors, - value: `${parsedValue} ${color}`.trim(), - color, - }); - } - - return CACHE.get(value); + merged.color = merged.colors[0]; + return merged; } /** * Parse color. Find it value, name and opacity. */ export function parseColor(val: string, ignoreError = false): ParsedColor { - val = val.trim(); - + val = (val ?? '').trim(); if (!val) return {}; - if (val.startsWith('#')) { - val = val.slice(1); + // Utilize the new parser to extract the first color token. + const processed = parseStyle(val as any); + const firstColor = processed.groups.find((g) => g.colors.length)?.colors[0]; - const tmp = val.split('.'); - - let opacity = 100; - - if (tmp.length > 1) { - if (tmp[1].length === 1) { - opacity = Number(tmp[1]) * 10; - } else { - opacity = Number(tmp[1]); - } - - if (Number.isNaN(opacity)) { - opacity = 100; - } - } - - const name = tmp[0]; - - let color; - - if (name === 'current') { - color = 'currentColor'; - } else { - if (opacity > 100) { - opacity = 100; - } else if (opacity < 0) { - opacity = 0; - } - } - - if (!color) { - color = - opacity !== 100 - ? rgbColorProp(name, Math.round(opacity) / 100) - : colorProp(name, null, strToRgb(`#${name}`)); - } - - return { - color, - name, - opacity: opacity != null ? opacity : 100, - }; - } - - let { values, mods, color } = parseStyle(val); - - let name, opacity; - - if (color) { - return { - color: (!color.startsWith('var(') ? strToRgb(color) : color) || color, - }; - } - - values.forEach((token) => { - if (token.match(/^((var|rgb|rgba|hsl|hsla)\(|#[0-9a-f]{3,6})/)) { - color = !token.startsWith('var') ? strToRgb(token) : token; - } else if (token.endsWith('%')) { - opacity = parseInt(token); - } - }); - - if (color) { - return { color }; - } - - name = name || mods[0]; - - if (!name) { + if (!firstColor) { if (!ignoreError && devMode) { - console.warn('CubeUIKit: incorrect color value:', val); + console.warn('CubeUIKit: unable to parse color:', val); } - return {}; } - if (!opacity) { - let color; + // Extract color name (if present) from variable pattern. + let nameMatch = firstColor.match(/var\(--([a-z0-9-]+)-color/); + if (!nameMatch) { + nameMatch = firstColor.match(/var\(--([a-z0-9-]+)-color-rgb/); + } - if (name === 'current') { - color = 'currentColor'; - } else if (name === 'inherit') { - color = 'inherit'; - } else if (name !== 'transparent' && name !== 'currentColor') { - color = `var(--${name}-color, ${name})`; - } else { - color = name; + let opacity: number | undefined; + if (firstColor.startsWith('rgb')) { + const alphaMatch = firstColor.match(/\/\s*([0-9.]+)\)/); + if (alphaMatch) { + const v = parseFloat(alphaMatch[1]); + if (!isNaN(v)) opacity = v * 100; } - - return { - name, - color, - }; } return { - color: rgbColorProp(name, Math.round(opacity) / 100), - name, + color: firstColor, + name: nameMatch ? nameMatch[1] : undefined, opacity, }; } @@ -610,10 +340,6 @@ export function transferMods(mods, from, to) { }); } -function prepareParsedValue(val) { - return val.trim().replace(PREPARE_REGEXP, (s, inner) => inner); -} - export function filterMods(mods, allowedMods) { return mods.filter((mod) => allowedMods.includes(mod)); }