@tenphi/eslint-plugin-tasty 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -263,12 +263,12 @@ not matched.
263
263
  | `tasty/no-classname-prop` | warn | `className` on recognized Tasty components; keep styling in Tasty or use `data-element` for sub-elements ([details](docs/rules/no-classname-prop.md)) |
264
264
  | `tasty/no-styles-prop` | warn | Instance `styles` on recognized Tasty components; use `tokens`, `mods`, exposed props/variants, or `tasty(Component, { styles })` ([details](docs/rules/no-styles-prop.md)) |
265
265
  | `tasty/no-protected-restyle` | warn | Restyling a design system's `protectedComponents`: instance `styles` and `*Styles`, appearance style props, and `tasty()` wrappers that restyle them, followed through aliases, wrappers and relative re-exports. Inactive without the config ([details](docs/rules/no-protected-restyle.md)) |
266
- | `tasty/consistent-token-usage` | warn | Nonzero raw pixel lengths, including compounds, expressions and numeric inputs to enhanced length handlers |
266
+ | `tasty/consistent-token-usage` | warn | Nonzero raw pixel lengths, including compounds, expressions and numeric inputs to enhanced length handlers. The `pixels: 'replaceable'` option reports only lengths with an exact unit, such as `8px` → `1x` ([details](docs/rules/consistent-token-usage.md)) |
267
267
  | `tasty/prefer-auto-calc` | warn | `calc(...)` instead of Tasty auto-calc `(...)` |
268
268
  | `tasty/prefer-state-negation` | warn | Top-level `:not(...)` in state keys instead of the `!` prefix (autofixable; nested CSS selectors stay intact) |
269
269
  | `tasty/prefer-custom-property-syntax` | warn | `var(--prop)` / `$x-color` / `transparent` / `currentColor` instead of `$prop` / `#color` / `#clear` / `#current` |
270
270
  | `tasty/prefer-hide` | warn | `display: 'none'` instead of `hide: true` |
271
- | `tasty/prefer-fill-mix` | warn | Deprecated two-color `fill` (`'#surface #dark.04'`) instead of a comma mix (`'#dark 4%, #surface'`), autofixed when the overlay's opacity is written on it; mix parts weighted by their opacity (`'#dark.04, #surface'`). Needs `@tenphi/tasty` 3.15 ([details](docs/rules/prefer-fill-mix.md)) |
271
+ | `tasty/prefer-fill-mix` | warn | Deprecated two-color `fill`, space-separated (`'#surface #dark.04'`) or comma-mixed (`'#dark 4%, #surface'`), instead of a compound color (`'#surface+#dark.04'`) or `color-mix()`, autofixed when the result is the same color; mix parts weighted by their opacity (`'#dark.04, #surface'`). Needs `@tenphi/tasty` 3.15, and 3.16 for compound colors ([details](docs/rules/prefer-fill-mix.md)) |
272
272
  | `tasty/prefer-directional-shorthand` | warn | 4-value `margin`/`padding`/`inset`/`radius`/`fade`/`border` with placeholder positions instead of directional form |
273
273
  | `tasty/prefer-longhand-property` | error | Lossy `flex` shorthand instead of `flexGrow` / `flexShrink` / `flexBasis` |
274
274
 
@@ -565,8 +565,11 @@ are checked only for enhanced handlers that convert them to pixels; unitless
565
565
  properties such as `opacity` and `zIndex` are unaffected. Known equivalents still
566
566
  have suggestions (`8px` → `1x`, radius `6px` → `1r`, border `1px` → `1bw`). These
567
567
  assume the conventional scale defaults; review them against your design system.
568
- Other pixels are report-only. Zero, token definitions, quoted CSS text, and URLs
569
- are allowed. Quotes and URLs are also excluded from token and raw-color checks.
568
+ Font size, line height and letter spacing are on the type scale, so they get no
569
+ `x` suggestion.
570
+ Other pixels are report-only. With the `pixels: 'replaceable'` option, they are
571
+ not reported, and only lengths with an exact unit are. Zero, token definitions,
572
+ quoted CSS text, and URLs are allowed. Quotes and URLs are also excluded from token and raw-color checks.
570
573
  Range-based fixes are withheld when JavaScript escapes or JSX entities make
571
574
  source offsets differ from the decoded value.
572
575
 
@@ -395,6 +395,7 @@ function classifyToken(raw, offset, errors, opts) {
395
395
  raw: token
396
396
  };
397
397
  }
398
+ if (token.includes("+")) return classifyCompoundColor(token, offset, errors);
398
399
  return classifyColorToken(token, offset, errors);
399
400
  }
400
401
  if (token.startsWith("(") && token.endsWith(")")) return {
@@ -614,6 +615,42 @@ function classifyColorToken(token, offset, errors) {
614
615
  raw
615
616
  };
616
617
  }
618
+ /** One layer of a compound color: a `#name`, before any opacity suffix. */
619
+ const RE_COMPOUND_LAYER = /^#[a-z0-9-]+(?:\.|$)/i;
620
+ /**
621
+ * Classify a compound color, `#surface+#dark.04`: color tokens joined by `+`,
622
+ * each with its own opacity, painted left to right. Tasty drops the whole value
623
+ * when a part is not a color token, so that is an error. The token's name is
624
+ * the compound as written, and it has no opacity of its own.
625
+ */
626
+ function classifyCompoundColor(token, offset, errors) {
627
+ const raw = token;
628
+ let layerOffset = offset;
629
+ for (const layer of token.split("+")) {
630
+ if (!RE_COMPOUND_LAYER.test(layer)) {
631
+ errors.push({
632
+ message: `'${raw}' is not a compound color: every part joined by '+' must be a color token, such as '#surface+#dark.04'.`,
633
+ offset,
634
+ length: raw.length,
635
+ raw
636
+ });
637
+ return {
638
+ type: "unknown",
639
+ raw
640
+ };
641
+ }
642
+ if (classifyColorToken(layer, layerOffset, errors).type !== "color-token") return {
643
+ type: "unknown",
644
+ raw
645
+ };
646
+ layerOffset += layer.length + 1;
647
+ }
648
+ return {
649
+ type: "color-token",
650
+ name: token.slice(1),
651
+ raw
652
+ };
653
+ }
617
654
  /**
618
655
  * Parse a style value string into typed tokens organized by
619
656
  * comma-separated groups and slash-separated parts.
@@ -1 +1 @@
1
- {"version":3,"file":"value-parser.js","names":[],"sources":["../../src/parsers/value-parser.ts"],"sourcesContent":["import { scanTokens, checkBracketBalance } from './utils.js';\nimport {\n BUILT_IN_UNITS,\n CSS_UNITS,\n NAMED_CSS_COLORS,\n SYSTEM_COLORS,\n} from '../constants.js';\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type ValueToken =\n | { type: 'color-token'; name: string; opacity?: string; raw: string }\n | { type: 'color-ref'; name: string; raw: string }\n /** A CSS named color (`rebeccapurple`) or system color (`Canvas`). */\n | { type: 'color-keyword'; raw: string }\n | { type: 'custom-prop'; name: string; raw: string }\n | { type: 'custom-prop-ref'; name: string; raw: string }\n | { type: 'custom-unit'; value: number; unit: string; raw: string }\n | { type: 'css-unit'; value: number; unit: string; raw: string }\n | { type: 'number'; value: number; raw: string }\n /** An unspaced `<ratio>`, such as `16/10`; the spaced form is two parts. */\n | { type: 'ratio'; raw: string }\n | { type: 'keyword'; value: string }\n | { type: 'css-function'; name: string; args: string; raw: string }\n /**\n * A Tasty CSS `@function` call: `$$name(args)` (emits native `--name(args)`).\n * Distinct from `custom-prop-ref`, which is the bare `$$name` form that\n * `transition` uses to name a custom property.\n */\n | { type: 'tasty-function'; name: string; args: string; raw: string }\n | { type: 'string'; value: string; raw: string }\n | { type: 'important'; raw: string }\n | { type: 'group-expr'; inner: string; raw: string }\n | { type: 'unknown'; raw: string };\n\nexport interface ValueError {\n message: string;\n offset: number;\n length: number;\n raw?: string;\n}\n\nexport interface ValuePart {\n tokens: ValueToken[];\n}\n\nexport interface ValueGroup {\n parts: ValuePart[];\n}\n\nexport interface ValueParseResult {\n groups: ValueGroup[];\n errors: ValueError[];\n}\n\nexport interface ValueParserOptions {\n knownUnits?: Set<string> | string[];\n knownFuncs?: Set<string> | string[];\n skipUnitValidation?: boolean;\n skipFuncValidation?: boolean;\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst RE_UNIT_NUM = /^[+-]?(?:\\d*\\.\\d+|\\d+)([a-z][a-z0-9]*)$/;\nconst RE_NUMBER = /^[+-]?(?:\\d*\\.\\d+|\\d+)$/;\nconst RE_CSS_UNIT_NUM = /^[+-]?(?:\\d*\\.\\d+|\\d+)([a-z%]+)$/;\n/** An unspaced `<ratio>`, such as `16/10`. */\nconst RE_RATIO = /^(?:\\d*\\.\\d+|\\d+)\\/(?:\\d*\\.\\d+|\\d+)$/;\n\n/** Functions the parser classifies as colors. */\nexport const COLOR_FUNCS = new Set([\n 'rgb',\n 'rgba',\n 'hsl',\n 'hsla',\n 'hwb',\n 'lab',\n 'lch',\n 'oklab',\n 'oklch',\n 'color',\n 'device-cmyk',\n 'gray',\n 'color-mix',\n 'color-contrast',\n]);\n\nconst CSS_FUNCS = new Set([\n 'calc',\n 'calc-size',\n 'min',\n 'max',\n 'clamp',\n 'abs',\n 'sign',\n 'round',\n 'mod',\n 'rem',\n 'sin',\n 'cos',\n 'tan',\n 'asin',\n 'acos',\n 'atan',\n 'atan2',\n 'pow',\n 'sqrt',\n 'hypot',\n 'log',\n 'exp',\n 'var',\n 'env',\n 'attr',\n 'counter',\n 'counters',\n 'image-set',\n 'cross-fade',\n 'image',\n 'paint',\n 'element',\n 'linear-gradient',\n 'radial-gradient',\n 'conic-gradient',\n 'repeating-linear-gradient',\n 'repeating-radial-gradient',\n 'repeating-conic-gradient',\n 'url',\n 'fit-content',\n 'minmax',\n 'repeat',\n 'cubic-bezier',\n 'steps',\n 'linear',\n 'rotate',\n 'scale',\n 'translate',\n 'skew',\n 'matrix',\n 'perspective',\n 'rotate3d',\n 'rotateX',\n 'rotateY',\n 'rotateZ',\n 'scale3d',\n 'scaleX',\n 'scaleY',\n 'scaleZ',\n 'translate3d',\n 'translateX',\n 'translateY',\n 'translateZ',\n 'skewX',\n 'skewY',\n 'matrix3d',\n 'blur',\n 'brightness',\n 'contrast',\n 'drop-shadow',\n 'grayscale',\n 'hue-rotate',\n 'invert',\n 'opacity',\n 'saturate',\n 'sepia',\n 'polygon',\n 'circle',\n 'ellipse',\n 'inset',\n 'path',\n 'xywh',\n 'rect',\n 'ray',\n 'light-dark',\n 'anchor',\n 'anchor-size',\n 'scroll',\n 'view',\n 'symbols',\n]);\n\nconst VALUE_KEYWORDS = new Set([\n 'auto',\n 'none',\n 'normal',\n 'inherit',\n 'initial',\n 'unset',\n 'revert',\n 'revert-layer',\n 'max-content',\n 'min-content',\n 'fit-content',\n 'stretch',\n 'transparent',\n 'currentcolor',\n 'currentColor',\n // display\n 'block',\n 'inline',\n 'inline-block',\n 'flex',\n 'inline-flex',\n 'grid',\n 'inline-grid',\n 'contents',\n 'table',\n 'table-row',\n 'table-cell',\n 'list-item',\n 'flow-root',\n // position\n 'static',\n 'relative',\n 'absolute',\n 'fixed',\n 'sticky',\n // overflow\n 'visible',\n 'hidden',\n 'scroll',\n 'clip',\n 'overlay',\n // flex/align\n 'center',\n 'start',\n 'end',\n 'flex-start',\n 'flex-end',\n 'space-between',\n 'space-around',\n 'space-evenly',\n 'baseline',\n // flow\n 'row',\n 'column',\n 'row-reverse',\n 'column-reverse',\n 'wrap',\n 'nowrap',\n 'dense',\n // border\n 'solid',\n 'dashed',\n 'dotted',\n 'double',\n 'groove',\n 'ridge',\n 'outset',\n // directional\n 'top',\n 'right',\n 'bottom',\n 'left',\n 'top-left',\n 'top-right',\n 'bottom-left',\n 'bottom-right',\n // radius shapes\n 'round',\n 'ellipse',\n 'leaf',\n 'backleaf',\n // dimension modifiers\n 'min',\n 'max',\n // text\n 'bold',\n 'bolder',\n 'lighter',\n 'italic',\n 'oblique',\n 'uppercase',\n 'lowercase',\n 'capitalize',\n 'line-through',\n 'underline',\n 'overline',\n 'wavy',\n // cursor\n 'pointer',\n 'default',\n 'text',\n 'move',\n 'grab',\n 'grabbing',\n 'not-allowed',\n 'crosshair',\n 'wait',\n 'help',\n 'col-resize',\n 'row-resize',\n 'n-resize',\n 's-resize',\n 'e-resize',\n 'w-resize',\n 'ne-resize',\n 'nw-resize',\n 'se-resize',\n 'sw-resize',\n 'ew-resize',\n 'ns-resize',\n 'zoom-in',\n 'zoom-out',\n // misc\n 'cover',\n 'contain',\n 'fill',\n 'no-repeat',\n 'repeat-x',\n 'repeat-y',\n 'border-box',\n 'padding-box',\n 'content-box',\n 'break-word',\n 'break-all',\n 'keep-all',\n 'anywhere',\n 'pre',\n 'pre-wrap',\n 'pre-line',\n 'balance',\n 'smooth',\n 'horizontal',\n 'vertical',\n 'both',\n 'mandatory',\n 'proximity',\n // white-space\n 'collapse',\n 'preserve',\n 'preserve-breaks',\n 'break-spaces',\n // text-wrap\n 'pretty',\n 'stable',\n // will-change\n 'transform',\n 'opacity',\n // animation\n 'infinite',\n 'alternate',\n 'alternate-reverse',\n 'reverse',\n 'forwards',\n 'backwards',\n 'running',\n 'paused',\n 'ease',\n 'ease-in',\n 'ease-out',\n 'ease-in-out',\n 'step-start',\n 'step-end',\n // scrollbar\n 'thin',\n 'always',\n 'both-edges',\n // width/height\n 'fixed',\n // shadow\n 'inset',\n // textOverflow\n 'clip',\n // other\n 'ltr',\n 'rtl',\n 'embed',\n 'isolate',\n 'isolate-override',\n 'plaintext',\n 'horizontal-tb',\n 'vertical-rl',\n 'vertical-lr',\n 'sideways-rl',\n 'sideways-lr',\n 'monospace',\n 'serif',\n 'sans-serif',\n 'cursive',\n 'fantasy',\n 'system-ui',\n 'ui-serif',\n 'ui-sans-serif',\n 'ui-monospace',\n 'ui-rounded',\n 'to',\n // misc\n 'strong',\n 'tight',\n 'icon',\n]);\n\n// ============================================================================\n// Classifier\n// ============================================================================\n\nfunction toSet(input?: Set<string> | string[]): Set<string> {\n if (!input) return new Set();\n return input instanceof Set ? input : new Set(input);\n}\n\nfunction classifyToken(\n raw: string,\n offset: number,\n errors: ValueError[],\n opts: ValueParserOptions,\n): ValueToken {\n const token = raw.trim();\n if (!token) return { type: 'keyword', value: '' };\n\n // Quoted strings\n if (\n (token.startsWith('\"') && token.endsWith('\"')) ||\n (token.startsWith(\"'\") && token.endsWith(\"'\"))\n ) {\n return { type: 'string', value: token.slice(1, -1), raw: token };\n }\n\n // !important\n if (token === '!important') {\n return { type: 'important', raw: token };\n }\n\n // Double prefix: $$name(...) is a Tasty `@function` call; bare $$name is the\n // custom-property reference `transition` uses. The call form must be checked\n // first, or the bare-name regex rejects `$$negative(10px)` outright.\n if (token.startsWith('$$') || token.startsWith('##')) {\n const call = classifyTastyFunctionCall(token, offset, errors);\n if (call) return call;\n }\n\n // Double prefix: $$name (custom property reference for transitions)\n if (token.startsWith('$$')) {\n const name = token.slice(2);\n if (/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n return { type: 'custom-prop-ref', name, raw: token };\n }\n errors.push({\n message: `Invalid custom property reference '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // Double prefix: ##name (color reference for transitions)\n if (token.startsWith('##')) {\n const name = token.slice(2);\n if (/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n return { type: 'color-ref', name, raw: token };\n }\n errors.push({\n message: `Invalid color reference '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // Custom property: $name\n if (token.startsWith('$')) {\n const name = token.slice(1);\n if (/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n return { type: 'custom-prop', name, raw: token };\n }\n errors.push({\n message: `Invalid custom property syntax '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // Color token: #name, #name.N, #name.$prop, or bare # (error)\n if (token.startsWith('#')) {\n if (token.length === 1) {\n errors.push({\n message: 'Empty color token name.',\n offset,\n length: 1,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n return classifyColorToken(token, offset, errors);\n }\n\n // Parenthesized expression: (expr) — fallback, auto-calc, etc.\n if (token.startsWith('(') && token.endsWith(')')) {\n return { type: 'group-expr', inner: token.slice(1, -1), raw: token };\n }\n\n // Function call: name(...)\n const openIdx = token.indexOf('(');\n if (openIdx > 0 && token.endsWith(')')) {\n const fname = token.slice(0, openIdx);\n const args = token.slice(openIdx + 1, -1);\n\n if (opts.skipFuncValidation) {\n return { type: 'css-function', name: fname, args, raw: token };\n }\n\n const knownFuncs = toSet(opts.knownFuncs);\n\n if (\n COLOR_FUNCS.has(fname) ||\n CSS_FUNCS.has(fname) ||\n knownFuncs.has(fname)\n ) {\n return { type: 'css-function', name: fname, args, raw: token };\n }\n\n // Unknown function — still return as css-function but flag it\n errors.push({\n message: `Unknown function '${fname}()'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'css-function', name: fname, args, raw: token };\n }\n\n // Unit number: 2x, 1.5r, 10px, 50%, 1fr\n const unitMatch = token.match(RE_UNIT_NUM);\n if (unitMatch) {\n const unit = unitMatch[1];\n const numVal = parseFloat(token.slice(0, -unit.length));\n\n if (opts.skipUnitValidation) {\n return { type: 'custom-unit', value: numVal, unit, raw: token };\n }\n\n const knownUnits = toSet(opts.knownUnits);\n\n if (BUILT_IN_UNITS.has(unit) || knownUnits.has(unit)) {\n return { type: 'custom-unit', value: numVal, unit, raw: token };\n }\n if (CSS_UNITS.has(unit)) {\n return { type: 'css-unit', value: numVal, unit, raw: token };\n }\n\n errors.push({\n message: `Unknown unit '${unit}' in '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // CSS unit with % (RE_UNIT_NUM doesn't match % since it expects alpha)\n const cssUnitMatch = token.match(RE_CSS_UNIT_NUM);\n if (cssUnitMatch) {\n const unit = cssUnitMatch[1];\n const numVal = parseFloat(token.slice(0, -unit.length));\n if (CSS_UNITS.has(unit)) {\n return { type: 'css-unit', value: numVal, unit, raw: token };\n }\n }\n\n // Plain number\n if (RE_NUMBER.test(token)) {\n return { type: 'number', value: parseFloat(token), raw: token };\n }\n\n // Unspaced ratio: 16/10\n if (RE_RATIO.test(token)) {\n return { type: 'ratio', raw: token };\n }\n\n // Tasty merge directive (@inherit)\n if (token === '@inherit') {\n return { type: 'keyword', value: token };\n }\n\n // Known keyword\n if (VALUE_KEYWORDS.has(token) || VALUE_KEYWORDS.has(token.toLowerCase())) {\n return { type: 'keyword', value: token };\n }\n\n // Color keyword: no token syntax, so the keyword itself is the color.\n // Matched case-insensitively, as CSS and Tasty match them.\n const lower = token.toLowerCase();\n if (NAMED_CSS_COLORS.has(lower) || SYSTEM_COLORS.has(lower)) {\n return { type: 'color-keyword', raw: token };\n }\n\n // CSS custom property function var(--name)\n if (token.startsWith('var(') && token.endsWith(')')) {\n return {\n type: 'css-function',\n name: 'var',\n args: token.slice(4, -1),\n raw: token,\n };\n }\n\n // Unknown token\n return { type: 'unknown', raw: token };\n}\n\n/**\n * Classify a `$$name(...)` / `##name(...)` Tasty `@function` call.\n *\n * Returns `null` when the token is not a call, so the caller falls through to the\n * bare `$$name` / `##name` reference handling that `transition` relies on.\n */\nfunction classifyTastyFunctionCall(\n token: string,\n offset: number,\n errors: ValueError[],\n): ValueToken | null {\n const openIdx = token.indexOf('(');\n\n if (openIdx <= 2 || !token.endsWith(')')) return null;\n\n const name = token.slice(2, openIdx);\n const args = token.slice(openIdx + 1, -1);\n\n if (!/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n errors.push({\n message: `Invalid function name in '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n return { type: 'tasty-function', name, args, raw: token };\n}\n\nfunction classifyColorToken(\n token: string,\n offset: number,\n errors: ValueError[],\n): ValueToken {\n const raw = token;\n\n // Strip leading #\n const name = token.slice(1);\n\n if (name.length === 0) {\n errors.push({\n message: 'Empty color token name.',\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n // Check for opacity suffix\n const dotIndex = name.indexOf('.');\n if (dotIndex !== -1) {\n const tokenName = name.slice(0, dotIndex);\n const opacitySuffix = name.slice(dotIndex + 1);\n\n if (tokenName.length === 0) {\n errors.push({\n message: 'Empty color token name before opacity.',\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n // Dynamic opacity from CSS custom property\n if (opacitySuffix.startsWith('$')) {\n return {\n type: 'color-token',\n name: tokenName,\n opacity: opacitySuffix,\n raw,\n };\n }\n\n if (opacitySuffix.length === 0) {\n errors.push({\n message: 'Trailing dot with no opacity value.',\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n // Tasty reads the digits as a decimal fraction (`.5` and `.50` are 50%,\n // `.150` is 15%), so any digit string is in range.\n if (!/^\\d+$/.test(opacitySuffix)) {\n errors.push({\n message: /^-\\d+$/.test(opacitySuffix)\n ? 'Opacity cannot be negative.'\n : `Invalid opacity value '${opacitySuffix}'.`,\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n return {\n type: 'color-token',\n name: tokenName,\n opacity: opacitySuffix,\n raw,\n };\n }\n\n return { type: 'color-token', name, raw };\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Parse a style value string into typed tokens organized by\n * comma-separated groups and slash-separated parts.\n */\nexport function parseValue(\n src: string,\n opts: ValueParserOptions = {},\n): ValueParseResult {\n const errors: ValueError[] = [];\n\n // Check bracket balance first\n const bracketError = checkBracketBalance(src);\n if (bracketError) {\n errors.push({\n message: bracketError.message,\n offset: bracketError.position,\n length: 1,\n });\n return { groups: [], errors };\n }\n\n const scanned = scanTokens(src);\n\n const groups: ValueGroup[] = [];\n let currentParts: ValuePart[] = [];\n let currentTokens: ValueToken[] = [];\n\n const endPart = () => {\n if (currentTokens.length > 0) {\n currentParts.push({ tokens: currentTokens });\n currentTokens = [];\n }\n };\n\n const endGroup = () => {\n endPart();\n if (currentParts.length > 0) {\n groups.push({ parts: currentParts });\n } else {\n groups.push({ parts: [{ tokens: [] }] });\n }\n currentParts = [];\n };\n\n for (const scanned_token of scanned) {\n if (scanned_token.value) {\n const classified = classifyToken(\n scanned_token.value,\n scanned_token.offset,\n errors,\n opts,\n );\n currentTokens.push(classified);\n }\n if (scanned_token.isSlash) endPart();\n if (scanned_token.isComma) endGroup();\n }\n\n // Push final group\n if (\n currentTokens.length > 0 ||\n currentParts.length > 0 ||\n groups.length === 0\n ) {\n endGroup();\n }\n\n return { groups, errors };\n}\n\n/**\n * Extract all tokens of a specific type from a parse result.\n */\nexport function extractTokensByType<T extends ValueToken['type']>(\n result: ValueParseResult,\n type: T,\n): Extract<ValueToken, { type: T }>[] {\n const tokens: Extract<ValueToken, { type: T }>[] = [];\n for (const group of result.groups) {\n for (const part of group.parts) {\n for (const token of part.tokens) {\n if (token.type === type) {\n tokens.push(token as Extract<ValueToken, { type: T }>);\n }\n }\n }\n }\n return tokens;\n}\n\n/**\n * Get a flat list of all tokens from a parse result.\n */\nexport function flattenTokens(result: ValueParseResult): ValueToken[] {\n const tokens: ValueToken[] = [];\n for (const group of result.groups) {\n for (const part of group.parts) {\n tokens.push(...part.tokens);\n }\n }\n return tokens;\n}\n"],"mappings":";;;;AAoEA,MAAM,cAAc;AACpB,MAAM,YAAY;AAClB,MAAM,kBAAkB;;AAExB,MAAM,WAAW;;AAGjB,MAAa,cAAc,IAAI,IAAI;CACjC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,YAAY,IAAI,IAAI;CACxB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,iBAAiB,IAAI,IAAI;CAC7B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CAEA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CA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"}
1
+ {"version":3,"file":"value-parser.js","names":[],"sources":["../../src/parsers/value-parser.ts"],"sourcesContent":["import { scanTokens, checkBracketBalance } from './utils.js';\nimport {\n BUILT_IN_UNITS,\n CSS_UNITS,\n NAMED_CSS_COLORS,\n SYSTEM_COLORS,\n} from '../constants.js';\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type ValueToken =\n | { type: 'color-token'; name: string; opacity?: string; raw: string }\n | { type: 'color-ref'; name: string; raw: string }\n /** A CSS named color (`rebeccapurple`) or system color (`Canvas`). */\n | { type: 'color-keyword'; raw: string }\n | { type: 'custom-prop'; name: string; raw: string }\n | { type: 'custom-prop-ref'; name: string; raw: string }\n | { type: 'custom-unit'; value: number; unit: string; raw: string }\n | { type: 'css-unit'; value: number; unit: string; raw: string }\n | { type: 'number'; value: number; raw: string }\n /** An unspaced `<ratio>`, such as `16/10`; the spaced form is two parts. */\n | { type: 'ratio'; raw: string }\n | { type: 'keyword'; value: string }\n | { type: 'css-function'; name: string; args: string; raw: string }\n /**\n * A Tasty CSS `@function` call: `$$name(args)` (emits native `--name(args)`).\n * Distinct from `custom-prop-ref`, which is the bare `$$name` form that\n * `transition` uses to name a custom property.\n */\n | { type: 'tasty-function'; name: string; args: string; raw: string }\n | { type: 'string'; value: string; raw: string }\n | { type: 'important'; raw: string }\n | { type: 'group-expr'; inner: string; raw: string }\n | { type: 'unknown'; raw: string };\n\nexport interface ValueError {\n message: string;\n offset: number;\n length: number;\n raw?: string;\n}\n\nexport interface ValuePart {\n tokens: ValueToken[];\n}\n\nexport interface ValueGroup {\n parts: ValuePart[];\n}\n\nexport interface ValueParseResult {\n groups: ValueGroup[];\n errors: ValueError[];\n}\n\nexport interface ValueParserOptions {\n knownUnits?: Set<string> | string[];\n knownFuncs?: Set<string> | string[];\n skipUnitValidation?: boolean;\n skipFuncValidation?: boolean;\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst RE_UNIT_NUM = /^[+-]?(?:\\d*\\.\\d+|\\d+)([a-z][a-z0-9]*)$/;\nconst RE_NUMBER = /^[+-]?(?:\\d*\\.\\d+|\\d+)$/;\nconst RE_CSS_UNIT_NUM = /^[+-]?(?:\\d*\\.\\d+|\\d+)([a-z%]+)$/;\n/** An unspaced `<ratio>`, such as `16/10`. */\nconst RE_RATIO = /^(?:\\d*\\.\\d+|\\d+)\\/(?:\\d*\\.\\d+|\\d+)$/;\n\n/** Functions the parser classifies as colors. */\nexport const COLOR_FUNCS = new Set([\n 'rgb',\n 'rgba',\n 'hsl',\n 'hsla',\n 'hwb',\n 'lab',\n 'lch',\n 'oklab',\n 'oklch',\n 'color',\n 'device-cmyk',\n 'gray',\n 'color-mix',\n 'color-contrast',\n]);\n\nconst CSS_FUNCS = new Set([\n 'calc',\n 'calc-size',\n 'min',\n 'max',\n 'clamp',\n 'abs',\n 'sign',\n 'round',\n 'mod',\n 'rem',\n 'sin',\n 'cos',\n 'tan',\n 'asin',\n 'acos',\n 'atan',\n 'atan2',\n 'pow',\n 'sqrt',\n 'hypot',\n 'log',\n 'exp',\n 'var',\n 'env',\n 'attr',\n 'counter',\n 'counters',\n 'image-set',\n 'cross-fade',\n 'image',\n 'paint',\n 'element',\n 'linear-gradient',\n 'radial-gradient',\n 'conic-gradient',\n 'repeating-linear-gradient',\n 'repeating-radial-gradient',\n 'repeating-conic-gradient',\n 'url',\n 'fit-content',\n 'minmax',\n 'repeat',\n 'cubic-bezier',\n 'steps',\n 'linear',\n 'rotate',\n 'scale',\n 'translate',\n 'skew',\n 'matrix',\n 'perspective',\n 'rotate3d',\n 'rotateX',\n 'rotateY',\n 'rotateZ',\n 'scale3d',\n 'scaleX',\n 'scaleY',\n 'scaleZ',\n 'translate3d',\n 'translateX',\n 'translateY',\n 'translateZ',\n 'skewX',\n 'skewY',\n 'matrix3d',\n 'blur',\n 'brightness',\n 'contrast',\n 'drop-shadow',\n 'grayscale',\n 'hue-rotate',\n 'invert',\n 'opacity',\n 'saturate',\n 'sepia',\n 'polygon',\n 'circle',\n 'ellipse',\n 'inset',\n 'path',\n 'xywh',\n 'rect',\n 'ray',\n 'light-dark',\n 'anchor',\n 'anchor-size',\n 'scroll',\n 'view',\n 'symbols',\n]);\n\nconst VALUE_KEYWORDS = new Set([\n 'auto',\n 'none',\n 'normal',\n 'inherit',\n 'initial',\n 'unset',\n 'revert',\n 'revert-layer',\n 'max-content',\n 'min-content',\n 'fit-content',\n 'stretch',\n 'transparent',\n 'currentcolor',\n 'currentColor',\n // display\n 'block',\n 'inline',\n 'inline-block',\n 'flex',\n 'inline-flex',\n 'grid',\n 'inline-grid',\n 'contents',\n 'table',\n 'table-row',\n 'table-cell',\n 'list-item',\n 'flow-root',\n // position\n 'static',\n 'relative',\n 'absolute',\n 'fixed',\n 'sticky',\n // overflow\n 'visible',\n 'hidden',\n 'scroll',\n 'clip',\n 'overlay',\n // flex/align\n 'center',\n 'start',\n 'end',\n 'flex-start',\n 'flex-end',\n 'space-between',\n 'space-around',\n 'space-evenly',\n 'baseline',\n // flow\n 'row',\n 'column',\n 'row-reverse',\n 'column-reverse',\n 'wrap',\n 'nowrap',\n 'dense',\n // border\n 'solid',\n 'dashed',\n 'dotted',\n 'double',\n 'groove',\n 'ridge',\n 'outset',\n // directional\n 'top',\n 'right',\n 'bottom',\n 'left',\n 'top-left',\n 'top-right',\n 'bottom-left',\n 'bottom-right',\n // radius shapes\n 'round',\n 'ellipse',\n 'leaf',\n 'backleaf',\n // dimension modifiers\n 'min',\n 'max',\n // text\n 'bold',\n 'bolder',\n 'lighter',\n 'italic',\n 'oblique',\n 'uppercase',\n 'lowercase',\n 'capitalize',\n 'line-through',\n 'underline',\n 'overline',\n 'wavy',\n // cursor\n 'pointer',\n 'default',\n 'text',\n 'move',\n 'grab',\n 'grabbing',\n 'not-allowed',\n 'crosshair',\n 'wait',\n 'help',\n 'col-resize',\n 'row-resize',\n 'n-resize',\n 's-resize',\n 'e-resize',\n 'w-resize',\n 'ne-resize',\n 'nw-resize',\n 'se-resize',\n 'sw-resize',\n 'ew-resize',\n 'ns-resize',\n 'zoom-in',\n 'zoom-out',\n // misc\n 'cover',\n 'contain',\n 'fill',\n 'no-repeat',\n 'repeat-x',\n 'repeat-y',\n 'border-box',\n 'padding-box',\n 'content-box',\n 'break-word',\n 'break-all',\n 'keep-all',\n 'anywhere',\n 'pre',\n 'pre-wrap',\n 'pre-line',\n 'balance',\n 'smooth',\n 'horizontal',\n 'vertical',\n 'both',\n 'mandatory',\n 'proximity',\n // white-space\n 'collapse',\n 'preserve',\n 'preserve-breaks',\n 'break-spaces',\n // text-wrap\n 'pretty',\n 'stable',\n // will-change\n 'transform',\n 'opacity',\n // animation\n 'infinite',\n 'alternate',\n 'alternate-reverse',\n 'reverse',\n 'forwards',\n 'backwards',\n 'running',\n 'paused',\n 'ease',\n 'ease-in',\n 'ease-out',\n 'ease-in-out',\n 'step-start',\n 'step-end',\n // scrollbar\n 'thin',\n 'always',\n 'both-edges',\n // width/height\n 'fixed',\n // shadow\n 'inset',\n // textOverflow\n 'clip',\n // other\n 'ltr',\n 'rtl',\n 'embed',\n 'isolate',\n 'isolate-override',\n 'plaintext',\n 'horizontal-tb',\n 'vertical-rl',\n 'vertical-lr',\n 'sideways-rl',\n 'sideways-lr',\n 'monospace',\n 'serif',\n 'sans-serif',\n 'cursive',\n 'fantasy',\n 'system-ui',\n 'ui-serif',\n 'ui-sans-serif',\n 'ui-monospace',\n 'ui-rounded',\n 'to',\n // misc\n 'strong',\n 'tight',\n 'icon',\n]);\n\n// ============================================================================\n// Classifier\n// ============================================================================\n\nfunction toSet(input?: Set<string> | string[]): Set<string> {\n if (!input) return new Set();\n return input instanceof Set ? input : new Set(input);\n}\n\nfunction classifyToken(\n raw: string,\n offset: number,\n errors: ValueError[],\n opts: ValueParserOptions,\n): ValueToken {\n const token = raw.trim();\n if (!token) return { type: 'keyword', value: '' };\n\n // Quoted strings\n if (\n (token.startsWith('\"') && token.endsWith('\"')) ||\n (token.startsWith(\"'\") && token.endsWith(\"'\"))\n ) {\n return { type: 'string', value: token.slice(1, -1), raw: token };\n }\n\n // !important\n if (token === '!important') {\n return { type: 'important', raw: token };\n }\n\n // Double prefix: $$name(...) is a Tasty `@function` call; bare $$name is the\n // custom-property reference `transition` uses. The call form must be checked\n // first, or the bare-name regex rejects `$$negative(10px)` outright.\n if (token.startsWith('$$') || token.startsWith('##')) {\n const call = classifyTastyFunctionCall(token, offset, errors);\n if (call) return call;\n }\n\n // Double prefix: $$name (custom property reference for transitions)\n if (token.startsWith('$$')) {\n const name = token.slice(2);\n if (/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n return { type: 'custom-prop-ref', name, raw: token };\n }\n errors.push({\n message: `Invalid custom property reference '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // Double prefix: ##name (color reference for transitions)\n if (token.startsWith('##')) {\n const name = token.slice(2);\n if (/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n return { type: 'color-ref', name, raw: token };\n }\n errors.push({\n message: `Invalid color reference '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // Custom property: $name\n if (token.startsWith('$')) {\n const name = token.slice(1);\n if (/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n return { type: 'custom-prop', name, raw: token };\n }\n errors.push({\n message: `Invalid custom property syntax '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // Color token: #name, #name.N, #name.$prop, or bare # (error)\n if (token.startsWith('#')) {\n if (token.length === 1) {\n errors.push({\n message: 'Empty color token name.',\n offset,\n length: 1,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n if (token.includes('+')) {\n return classifyCompoundColor(token, offset, errors);\n }\n return classifyColorToken(token, offset, errors);\n }\n\n // Parenthesized expression: (expr) — fallback, auto-calc, etc.\n if (token.startsWith('(') && token.endsWith(')')) {\n return { type: 'group-expr', inner: token.slice(1, -1), raw: token };\n }\n\n // Function call: name(...)\n const openIdx = token.indexOf('(');\n if (openIdx > 0 && token.endsWith(')')) {\n const fname = token.slice(0, openIdx);\n const args = token.slice(openIdx + 1, -1);\n\n if (opts.skipFuncValidation) {\n return { type: 'css-function', name: fname, args, raw: token };\n }\n\n const knownFuncs = toSet(opts.knownFuncs);\n\n if (\n COLOR_FUNCS.has(fname) ||\n CSS_FUNCS.has(fname) ||\n knownFuncs.has(fname)\n ) {\n return { type: 'css-function', name: fname, args, raw: token };\n }\n\n // Unknown function — still return as css-function but flag it\n errors.push({\n message: `Unknown function '${fname}()'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'css-function', name: fname, args, raw: token };\n }\n\n // Unit number: 2x, 1.5r, 10px, 50%, 1fr\n const unitMatch = token.match(RE_UNIT_NUM);\n if (unitMatch) {\n const unit = unitMatch[1];\n const numVal = parseFloat(token.slice(0, -unit.length));\n\n if (opts.skipUnitValidation) {\n return { type: 'custom-unit', value: numVal, unit, raw: token };\n }\n\n const knownUnits = toSet(opts.knownUnits);\n\n if (BUILT_IN_UNITS.has(unit) || knownUnits.has(unit)) {\n return { type: 'custom-unit', value: numVal, unit, raw: token };\n }\n if (CSS_UNITS.has(unit)) {\n return { type: 'css-unit', value: numVal, unit, raw: token };\n }\n\n errors.push({\n message: `Unknown unit '${unit}' in '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n // CSS unit with % (RE_UNIT_NUM doesn't match % since it expects alpha)\n const cssUnitMatch = token.match(RE_CSS_UNIT_NUM);\n if (cssUnitMatch) {\n const unit = cssUnitMatch[1];\n const numVal = parseFloat(token.slice(0, -unit.length));\n if (CSS_UNITS.has(unit)) {\n return { type: 'css-unit', value: numVal, unit, raw: token };\n }\n }\n\n // Plain number\n if (RE_NUMBER.test(token)) {\n return { type: 'number', value: parseFloat(token), raw: token };\n }\n\n // Unspaced ratio: 16/10\n if (RE_RATIO.test(token)) {\n return { type: 'ratio', raw: token };\n }\n\n // Tasty merge directive (@inherit)\n if (token === '@inherit') {\n return { type: 'keyword', value: token };\n }\n\n // Known keyword\n if (VALUE_KEYWORDS.has(token) || VALUE_KEYWORDS.has(token.toLowerCase())) {\n return { type: 'keyword', value: token };\n }\n\n // Color keyword: no token syntax, so the keyword itself is the color.\n // Matched case-insensitively, as CSS and Tasty match them.\n const lower = token.toLowerCase();\n if (NAMED_CSS_COLORS.has(lower) || SYSTEM_COLORS.has(lower)) {\n return { type: 'color-keyword', raw: token };\n }\n\n // CSS custom property function var(--name)\n if (token.startsWith('var(') && token.endsWith(')')) {\n return {\n type: 'css-function',\n name: 'var',\n args: token.slice(4, -1),\n raw: token,\n };\n }\n\n // Unknown token\n return { type: 'unknown', raw: token };\n}\n\n/**\n * Classify a `$$name(...)` / `##name(...)` Tasty `@function` call.\n *\n * Returns `null` when the token is not a call, so the caller falls through to the\n * bare `$$name` / `##name` reference handling that `transition` relies on.\n */\nfunction classifyTastyFunctionCall(\n token: string,\n offset: number,\n errors: ValueError[],\n): ValueToken | null {\n const openIdx = token.indexOf('(');\n\n if (openIdx <= 2 || !token.endsWith(')')) return null;\n\n const name = token.slice(2, openIdx);\n const args = token.slice(openIdx + 1, -1);\n\n if (!/^[a-z_][a-z0-9-_]*$/i.test(name)) {\n errors.push({\n message: `Invalid function name in '${token}'.`,\n offset,\n length: token.length,\n raw: token,\n });\n return { type: 'unknown', raw: token };\n }\n\n return { type: 'tasty-function', name, args, raw: token };\n}\n\nfunction classifyColorToken(\n token: string,\n offset: number,\n errors: ValueError[],\n): ValueToken {\n const raw = token;\n\n // Strip leading #\n const name = token.slice(1);\n\n if (name.length === 0) {\n errors.push({\n message: 'Empty color token name.',\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n // Check for opacity suffix\n const dotIndex = name.indexOf('.');\n if (dotIndex !== -1) {\n const tokenName = name.slice(0, dotIndex);\n const opacitySuffix = name.slice(dotIndex + 1);\n\n if (tokenName.length === 0) {\n errors.push({\n message: 'Empty color token name before opacity.',\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n // Dynamic opacity from CSS custom property\n if (opacitySuffix.startsWith('$')) {\n return {\n type: 'color-token',\n name: tokenName,\n opacity: opacitySuffix,\n raw,\n };\n }\n\n if (opacitySuffix.length === 0) {\n errors.push({\n message: 'Trailing dot with no opacity value.',\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n // Tasty reads the digits as a decimal fraction (`.5` and `.50` are 50%,\n // `.150` is 15%), so any digit string is in range.\n if (!/^\\d+$/.test(opacitySuffix)) {\n errors.push({\n message: /^-\\d+$/.test(opacitySuffix)\n ? 'Opacity cannot be negative.'\n : `Invalid opacity value '${opacitySuffix}'.`,\n offset,\n length: token.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n return {\n type: 'color-token',\n name: tokenName,\n opacity: opacitySuffix,\n raw,\n };\n }\n\n return { type: 'color-token', name, raw };\n}\n\n/** One layer of a compound color: a `#name`, before any opacity suffix. */\nconst RE_COMPOUND_LAYER = /^#[a-z0-9-]+(?:\\.|$)/i;\n\n/**\n * Classify a compound color, `#surface+#dark.04`: color tokens joined by `+`,\n * each with its own opacity, painted left to right. Tasty drops the whole value\n * when a part is not a color token, so that is an error. The token's name is\n * the compound as written, and it has no opacity of its own.\n */\nfunction classifyCompoundColor(\n token: string,\n offset: number,\n errors: ValueError[],\n): ValueToken {\n const raw = token;\n let layerOffset = offset;\n\n for (const layer of token.split('+')) {\n if (!RE_COMPOUND_LAYER.test(layer)) {\n errors.push({\n message: `'${raw}' is not a compound color: every part joined by '+' must be a color token, such as '#surface+#dark.04'.`,\n offset,\n length: raw.length,\n raw,\n });\n return { type: 'unknown', raw };\n }\n\n if (classifyColorToken(layer, layerOffset, errors).type !== 'color-token') {\n return { type: 'unknown', raw };\n }\n layerOffset += layer.length + 1;\n }\n\n return { type: 'color-token', name: token.slice(1), raw };\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Parse a style value string into typed tokens organized by\n * comma-separated groups and slash-separated parts.\n */\nexport function parseValue(\n src: string,\n opts: ValueParserOptions = {},\n): ValueParseResult {\n const errors: ValueError[] = [];\n\n // Check bracket balance first\n const bracketError = checkBracketBalance(src);\n if (bracketError) {\n errors.push({\n message: bracketError.message,\n offset: bracketError.position,\n length: 1,\n });\n return { groups: [], errors };\n }\n\n const scanned = scanTokens(src);\n\n const groups: ValueGroup[] = [];\n let currentParts: ValuePart[] = [];\n let currentTokens: ValueToken[] = [];\n\n const endPart = () => {\n if (currentTokens.length > 0) {\n currentParts.push({ tokens: currentTokens });\n currentTokens = [];\n }\n };\n\n const endGroup = () => {\n endPart();\n if (currentParts.length > 0) {\n groups.push({ parts: currentParts });\n } else {\n groups.push({ parts: [{ tokens: [] }] });\n }\n currentParts = [];\n };\n\n for (const scanned_token of scanned) {\n if (scanned_token.value) {\n const classified = classifyToken(\n scanned_token.value,\n scanned_token.offset,\n errors,\n opts,\n );\n currentTokens.push(classified);\n }\n if (scanned_token.isSlash) endPart();\n if (scanned_token.isComma) endGroup();\n }\n\n // Push final group\n if (\n currentTokens.length > 0 ||\n currentParts.length > 0 ||\n groups.length === 0\n ) {\n endGroup();\n }\n\n return { groups, errors };\n}\n\n/**\n * Extract all tokens of a specific type from a parse result.\n */\nexport function extractTokensByType<T extends ValueToken['type']>(\n result: ValueParseResult,\n type: T,\n): Extract<ValueToken, { type: T }>[] {\n const tokens: Extract<ValueToken, { type: T }>[] = [];\n for (const group of result.groups) {\n for (const part of group.parts) {\n for (const token of part.tokens) {\n if (token.type === type) {\n tokens.push(token as Extract<ValueToken, { type: T }>);\n }\n }\n }\n }\n return tokens;\n}\n\n/**\n * Get a flat list of all tokens from a parse result.\n */\nexport function flattenTokens(result: ValueParseResult): ValueToken[] {\n const tokens: ValueToken[] = [];\n for (const group of result.groups) {\n for (const part of group.parts) {\n tokens.push(...part.tokens);\n }\n }\n return tokens;\n}\n"],"mappings":";;;;AAoEA,MAAM,cAAc;AACpB,MAAM,YAAY;AAClB,MAAM,kBAAkB;;AAExB,MAAM,WAAW;;AAGjB,MAAa,cAAc,IAAI,IAAI;CACjC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,YAAY,IAAI,IAAI;CACxB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,iBAAiB,IAAI,IAAI;CAC7B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CAEA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CA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"}
@@ -17,6 +17,15 @@ import { replaceInStringValue } from "../fix-utils.js";
17
17
  * happened to use.
18
18
  */
19
19
  const BORDER_WIDTH_PROPERTIES = new Set(["border", ...LOGICAL_BORDER_STYLES]);
20
+ /**
21
+ * Lengths on the type scale, which presets set. `x` is the spacing scale, so a
22
+ * `16px` line height equals `2x` only by coincidence and gets no suggestion.
23
+ */
24
+ const TYPE_SCALE_PROPERTIES = new Set([
25
+ "fontSize",
26
+ "lineHeight",
27
+ "letterSpacing"
28
+ ]);
20
29
  /** Enhanced handlers that convert numeric inputs to pixels. Unitless CSS stays out. */
21
30
  const NUMERIC_PIXEL_PROPERTIES = new Set([
22
31
  ...LOGICAL_STYLES,
@@ -81,32 +90,40 @@ var consistent_token_usage_default = createRule({
81
90
  preferToken: "Consider using '{{suggestion}}' instead of '{{raw}}'. For an intentional pixel value, explicitly disable tasty/consistent-token-usage on this line with a reason.",
82
91
  replaceWithToken: "Replace '{{raw}}' with '{{suggestion}}'"
83
92
  },
84
- schema: []
93
+ schema: [{
94
+ type: "object",
95
+ properties: { pixels: {
96
+ type: "string",
97
+ enum: ["all", "replaceable"]
98
+ } },
99
+ additionalProperties: false
100
+ }]
85
101
  },
86
- defaultOptions: [],
87
- create(context) {
102
+ defaultOptions: [{ pixels: "all" }],
103
+ create(context, [{ pixels = "all" }]) {
88
104
  const ctx = new TastyContext(context);
89
105
  function checkValue(property, value, node) {
90
106
  if (/^[#$]/.test(property)) return;
91
- const pixels = /* @__PURE__ */ new Map();
107
+ const lengths = /* @__PURE__ */ new Map();
92
108
  for (const word of scanValueWords(value)) {
93
109
  if (!/^[+-]?(?:\d*\.\d+|\d+\.?\d*)(?:e[+-]?\d+)?px$/i.test(word.value)) continue;
94
110
  const amount = Number(word.value.slice(0, -2));
95
111
  if (amount === 0) continue;
96
112
  const raw = word.value;
97
113
  const canonical = `${amount}px`;
98
- const suggestion = property === "radius" && amount === 6 ? "1r" : BORDER_WIDTH_PROPERTIES.has(property) && amount === 1 ? "1bw" : PX_TO_UNIT[canonical];
99
- const edits = pixels.get(raw) ?? [];
114
+ const suggestion = property === "radius" && amount === 6 ? "1r" : BORDER_WIDTH_PROPERTIES.has(property) && amount === 1 ? "1bw" : TYPE_SCALE_PROPERTIES.has(property) ? void 0 : PX_TO_UNIT[canonical];
115
+ const edits = lengths.get(raw) ?? [];
100
116
  edits.push({
101
117
  start: word.offset,
102
118
  end: word.offset + raw.length,
103
119
  replacement: suggestion ?? ""
104
120
  });
105
- pixels.set(raw, edits);
121
+ lengths.set(raw, edits);
106
122
  }
107
- for (const [raw, edits] of pixels) {
123
+ for (const [raw, edits] of lengths) {
108
124
  const suggestion = edits[0].replacement;
109
125
  if (!suggestion) {
126
+ if (pixels === "replaceable") continue;
110
127
  context.report({
111
128
  node,
112
129
  messageId: "rawPixelValue",
@@ -1 +1 @@
1
- {"version":3,"file":"consistent-token-usage.js","names":[],"sources":["../../src/rules/consistent-token-usage.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext } from '../context.js';\nimport { styleValueListeners } from '../style-values.js';\nimport { LOGICAL_BORDER_STYLES, LOGICAL_STYLES } from '../constants.js';\nimport { getStringValue } from '../utils.js';\nimport { replaceInStringValue } from '../fix-utils.js';\nimport { scanValueWords } from '../value-words.js';\n\ntype MessageIds = 'preferToken' | 'rawPixelValue' | 'replaceWithToken';\n\n/**\n * Properties whose value carries a border width, so a `1px` in one is the\n * `1bw` token spelled out.\n *\n * The logical axes belong here for the same reason `border` does: they take the\n * same width/style/colour value, parsed by the same `parseBorderValue`. Leaving\n * them out would make the advice depend on which axis vocabulary the author\n * happened to use.\n */\nconst BORDER_WIDTH_PROPERTIES = new Set<string>([\n 'border',\n ...LOGICAL_BORDER_STYLES,\n]);\n\n/** Enhanced handlers that convert numeric inputs to pixels. Unitless CSS stays out. */\nconst NUMERIC_PIXEL_PROPERTIES = new Set<string>([\n ...LOGICAL_STYLES,\n 'gap',\n 'radius',\n 'outlineOffset',\n 'width',\n 'minWidth',\n 'maxWidth',\n 'height',\n 'minHeight',\n 'maxHeight',\n 'padding',\n 'paddingTop',\n 'paddingRight',\n 'paddingBottom',\n 'paddingLeft',\n 'margin',\n 'marginTop',\n 'marginRight',\n 'marginBottom',\n 'marginLeft',\n 'inset',\n 'top',\n 'right',\n 'bottom',\n 'left',\n 'border',\n 'borderTop',\n 'borderRight',\n 'borderBottom',\n 'borderLeft',\n 'scrollPadding',\n 'scrollPaddingTop',\n 'scrollPaddingRight',\n 'scrollPaddingBottom',\n 'scrollPaddingLeft',\n 'scrollMargin',\n 'scrollMarginTop',\n 'scrollMarginRight',\n 'scrollMarginBottom',\n 'scrollMarginLeft',\n]);\n\nconst PX_TO_UNIT: Record<string, string> = {\n '8px': '1x',\n '16px': '2x',\n '24px': '3x',\n '32px': '4x',\n '40px': '5x',\n '48px': '6x',\n '56px': '7x',\n '64px': '8x',\n};\n\nexport default createRule<[], MessageIds>({\n name: 'consistent-token-usage',\n meta: {\n type: 'suggestion',\n hasSuggestions: true,\n docs: {\n description:\n 'Suggest using design tokens and custom units instead of raw CSS values',\n },\n messages: {\n rawPixelValue:\n \"Use a design token or custom unit instead of raw pixel value '{{raw}}'. For an intentional pixel value, explicitly disable tasty/consistent-token-usage on this line with a reason.\",\n preferToken:\n \"Consider using '{{suggestion}}' instead of '{{raw}}'. For an intentional pixel value, explicitly disable tasty/consistent-token-usage on this line with a reason.\",\n replaceWithToken: \"Replace '{{raw}}' with '{{suggestion}}'\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n\n function checkValue(\n property: string,\n value: string,\n node: TSESTree.Node,\n ): void {\n // Definitions establish the scale itself; quoted text and URLs are opaque.\n if (/^[#$]/.test(property)) return;\n const pixels = new Map<\n string,\n { start: number; end: number; replacement: string }[]\n >();\n for (const word of scanValueWords(value)) {\n if (!/^[+-]?(?:\\d*\\.\\d+|\\d+\\.?\\d*)(?:e[+-]?\\d+)?px$/i.test(word.value))\n continue;\n const amount = Number(word.value.slice(0, -2));\n if (amount === 0) continue;\n const raw = word.value;\n const canonical = `${amount}px`;\n const suggestion =\n property === 'radius' && amount === 6\n ? '1r'\n : BORDER_WIDTH_PROPERTIES.has(property) && amount === 1\n ? '1bw'\n : PX_TO_UNIT[canonical];\n const edits = pixels.get(raw) ?? [];\n edits.push({\n start: word.offset,\n end: word.offset + raw.length,\n replacement: suggestion ?? '',\n });\n pixels.set(raw, edits);\n }\n for (const [raw, edits] of pixels) {\n const suggestion = edits[0].replacement;\n if (!suggestion) {\n context.report({ node, messageId: 'rawPixelValue', data: { raw } });\n continue;\n }\n context.report({\n node,\n messageId: 'preferToken',\n data: { suggestion, raw },\n suggest: [\n {\n messageId: 'replaceWithToken',\n data: { raw, suggestion },\n fix: (fixer) =>\n getStringValue(node) === null\n ? fixer.replaceText(node, `'${suggestion}'`)\n : replaceInStringValue(\n fixer,\n node,\n edits,\n context.sourceCode,\n ),\n },\n ],\n });\n }\n }\n\n return styleValueListeners(ctx, (property, node) => {\n const value = getStringValue(node);\n if (value !== null) {\n checkValue(property, value, node);\n } else if (NUMERIC_PIXEL_PROPERTIES.has(property)) {\n if (node.type === 'Literal' && typeof node.value === 'number') {\n checkValue(property, `${node.value}px`, node);\n } else if (\n node.type === 'UnaryExpression' &&\n (node.operator === '-' || node.operator === '+') &&\n node.argument.type === 'Literal' &&\n typeof node.argument.value === 'number'\n ) {\n const amount =\n node.operator === '-' ? -node.argument.value : node.argument.value;\n checkValue(property, `${amount}px`, node);\n }\n }\n });\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;AAoBA,MAAM,0BAA0B,IAAI,IAAY,CAC9C,UACA,GAAG,sBACJ,CAAC;;AAGF,MAAM,2BAA2B,IAAI,IAAY;CAC/C,GAAG;CACH;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;AAEF,MAAM,aAAqC;CACzC,OAAO;CACP,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;CACT;AAED,qCAAe,WAA2B;CACxC,MAAM;CACN,MAAM;EACJ,MAAM;EACN,gBAAgB;EAChB,MAAM,EACJ,aACE,0EACH;EACD,UAAU;GACR,eACE;GACF,aACE;GACF,kBAAkB;GACnB;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;EACd,MAAM,MAAM,IAAI,aAAa,QAAQ;EAErC,SAAS,WACP,UACA,OACA,MACM;AAEN,OAAI,QAAQ,KAAK,SAAS,CAAE;GAC5B,MAAM,yBAAS,IAAI,KAGhB;AACH,QAAK,MAAM,QAAQ,eAAe,MAAM,EAAE;AACxC,QAAI,CAAC,iDAAiD,KAAK,KAAK,MAAM,CACpE;IACF,MAAM,SAAS,OAAO,KAAK,MAAM,MAAM,GAAG,GAAG,CAAC;AAC9C,QAAI,WAAW,EAAG;IAClB,MAAM,MAAM,KAAK;IACjB,MAAM,YAAY,GAAG,OAAO;IAC5B,MAAM,aACJ,aAAa,YAAY,WAAW,IAChC,OACA,wBAAwB,IAAI,SAAS,IAAI,WAAW,IAClD,QACA,WAAW;IACnB,MAAM,QAAQ,OAAO,IAAI,IAAI,IAAI,EAAE;AACnC,UAAM,KAAK;KACT,OAAO,KAAK;KACZ,KAAK,KAAK,SAAS,IAAI;KACvB,aAAa,cAAc;KAC5B,CAAC;AACF,WAAO,IAAI,KAAK,MAAM;;AAExB,QAAK,MAAM,CAAC,KAAK,UAAU,QAAQ;IACjC,MAAM,aAAa,MAAM,GAAG;AAC5B,QAAI,CAAC,YAAY;AACf,aAAQ,OAAO;MAAE;MAAM,WAAW;MAAiB,MAAM,EAAE,KAAK;MAAE,CAAC;AACnE;;AAEF,YAAQ,OAAO;KACb;KACA,WAAW;KACX,MAAM;MAAE;MAAY;MAAK;KACzB,SAAS,CACP;MACE,WAAW;MACX,MAAM;OAAE;OAAK;OAAY;MACzB,MAAM,UACJ,eAAe,KAAK,KAAK,OACrB,MAAM,YAAY,MAAM,IAAI,WAAW,GAAG,GAC1C,qBACE,OACA,MACA,OACA,QAAQ,WACT;MACR,CACF;KACF,CAAC;;;AAIN,SAAO,oBAAoB,MAAM,UAAU,SAAS;GAClD,MAAM,QAAQ,eAAe,KAAK;AAClC,OAAI,UAAU,KACZ,YAAW,UAAU,OAAO,KAAK;YACxB,yBAAyB,IAAI,SAAS,EAC/C;QAAI,KAAK,SAAS,aAAa,OAAO,KAAK,UAAU,SACnD,YAAW,UAAU,GAAG,KAAK,MAAM,KAAK,KAAK;aAE7C,KAAK,SAAS,sBACb,KAAK,aAAa,OAAO,KAAK,aAAa,QAC5C,KAAK,SAAS,SAAS,aACvB,OAAO,KAAK,SAAS,UAAU,SAI/B,YAAW,UAAU,GADnB,KAAK,aAAa,MAAM,CAAC,KAAK,SAAS,QAAQ,KAAK,SAAS,MAChC,KAAK,KAAK;;IAG7C;;CAEL,CAAC"}
1
+ {"version":3,"file":"consistent-token-usage.js","names":[],"sources":["../../src/rules/consistent-token-usage.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext } from '../context.js';\nimport { styleValueListeners } from '../style-values.js';\nimport { LOGICAL_BORDER_STYLES, LOGICAL_STYLES } from '../constants.js';\nimport { getStringValue } from '../utils.js';\nimport { replaceInStringValue } from '../fix-utils.js';\nimport { scanValueWords } from '../value-words.js';\n\ntype MessageIds = 'preferToken' | 'rawPixelValue' | 'replaceWithToken';\n\n/**\n * Which pixel lengths to report: every one, or only those with an exact\n * replacement, so a project can enforce the known units without listing every\n * layout size it chose in pixels.\n */\ntype Pixels = 'all' | 'replaceable';\n\n/**\n * Properties whose value carries a border width, so a `1px` in one is the\n * `1bw` token spelled out.\n *\n * The logical axes belong here for the same reason `border` does: they take the\n * same width/style/colour value, parsed by the same `parseBorderValue`. Leaving\n * them out would make the advice depend on which axis vocabulary the author\n * happened to use.\n */\nconst BORDER_WIDTH_PROPERTIES = new Set<string>([\n 'border',\n ...LOGICAL_BORDER_STYLES,\n]);\n\n/**\n * Lengths on the type scale, which presets set. `x` is the spacing scale, so a\n * `16px` line height equals `2x` only by coincidence and gets no suggestion.\n */\nconst TYPE_SCALE_PROPERTIES = new Set<string>([\n 'fontSize',\n 'lineHeight',\n 'letterSpacing',\n]);\n\n/** Enhanced handlers that convert numeric inputs to pixels. Unitless CSS stays out. */\nconst NUMERIC_PIXEL_PROPERTIES = new Set<string>([\n ...LOGICAL_STYLES,\n 'gap',\n 'radius',\n 'outlineOffset',\n 'width',\n 'minWidth',\n 'maxWidth',\n 'height',\n 'minHeight',\n 'maxHeight',\n 'padding',\n 'paddingTop',\n 'paddingRight',\n 'paddingBottom',\n 'paddingLeft',\n 'margin',\n 'marginTop',\n 'marginRight',\n 'marginBottom',\n 'marginLeft',\n 'inset',\n 'top',\n 'right',\n 'bottom',\n 'left',\n 'border',\n 'borderTop',\n 'borderRight',\n 'borderBottom',\n 'borderLeft',\n 'scrollPadding',\n 'scrollPaddingTop',\n 'scrollPaddingRight',\n 'scrollPaddingBottom',\n 'scrollPaddingLeft',\n 'scrollMargin',\n 'scrollMarginTop',\n 'scrollMarginRight',\n 'scrollMarginBottom',\n 'scrollMarginLeft',\n]);\n\nconst PX_TO_UNIT: Record<string, string> = {\n '8px': '1x',\n '16px': '2x',\n '24px': '3x',\n '32px': '4x',\n '40px': '5x',\n '48px': '6x',\n '56px': '7x',\n '64px': '8x',\n};\n\nexport default createRule<[{ pixels?: Pixels }], MessageIds>({\n name: 'consistent-token-usage',\n meta: {\n type: 'suggestion',\n hasSuggestions: true,\n docs: {\n description:\n 'Suggest using design tokens and custom units instead of raw CSS values',\n },\n messages: {\n rawPixelValue:\n \"Use a design token or custom unit instead of raw pixel value '{{raw}}'. For an intentional pixel value, explicitly disable tasty/consistent-token-usage on this line with a reason.\",\n preferToken:\n \"Consider using '{{suggestion}}' instead of '{{raw}}'. For an intentional pixel value, explicitly disable tasty/consistent-token-usage on this line with a reason.\",\n replaceWithToken: \"Replace '{{raw}}' with '{{suggestion}}'\",\n },\n schema: [\n {\n type: 'object',\n properties: {\n pixels: { type: 'string', enum: ['all', 'replaceable'] },\n },\n additionalProperties: false,\n },\n ],\n },\n defaultOptions: [{ pixels: 'all' }],\n create(context, [{ pixels = 'all' }]) {\n const ctx = new TastyContext(context);\n\n function checkValue(\n property: string,\n value: string,\n node: TSESTree.Node,\n ): void {\n // Definitions establish the scale itself; quoted text and URLs are opaque.\n if (/^[#$]/.test(property)) return;\n const lengths = new Map<\n string,\n { start: number; end: number; replacement: string }[]\n >();\n for (const word of scanValueWords(value)) {\n if (!/^[+-]?(?:\\d*\\.\\d+|\\d+\\.?\\d*)(?:e[+-]?\\d+)?px$/i.test(word.value))\n continue;\n const amount = Number(word.value.slice(0, -2));\n if (amount === 0) continue;\n const raw = word.value;\n const canonical = `${amount}px`;\n const suggestion =\n property === 'radius' && amount === 6\n ? '1r'\n : BORDER_WIDTH_PROPERTIES.has(property) && amount === 1\n ? '1bw'\n : TYPE_SCALE_PROPERTIES.has(property)\n ? undefined\n : PX_TO_UNIT[canonical];\n const edits = lengths.get(raw) ?? [];\n edits.push({\n start: word.offset,\n end: word.offset + raw.length,\n replacement: suggestion ?? '',\n });\n lengths.set(raw, edits);\n }\n for (const [raw, edits] of lengths) {\n const suggestion = edits[0].replacement;\n if (!suggestion) {\n if (pixels === 'replaceable') continue;\n context.report({ node, messageId: 'rawPixelValue', data: { raw } });\n continue;\n }\n context.report({\n node,\n messageId: 'preferToken',\n data: { suggestion, raw },\n suggest: [\n {\n messageId: 'replaceWithToken',\n data: { raw, suggestion },\n fix: (fixer) =>\n getStringValue(node) === null\n ? fixer.replaceText(node, `'${suggestion}'`)\n : replaceInStringValue(\n fixer,\n node,\n edits,\n context.sourceCode,\n ),\n },\n ],\n });\n }\n }\n\n return styleValueListeners(ctx, (property, node) => {\n const value = getStringValue(node);\n if (value !== null) {\n checkValue(property, value, node);\n } else if (NUMERIC_PIXEL_PROPERTIES.has(property)) {\n if (node.type === 'Literal' && typeof node.value === 'number') {\n checkValue(property, `${node.value}px`, node);\n } else if (\n node.type === 'UnaryExpression' &&\n (node.operator === '-' || node.operator === '+') &&\n node.argument.type === 'Literal' &&\n typeof node.argument.value === 'number'\n ) {\n const amount =\n node.operator === '-' ? -node.argument.value : node.argument.value;\n checkValue(property, `${amount}px`, node);\n }\n }\n });\n },\n});\n"],"mappings":"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"}
@@ -10,6 +10,11 @@ import semver from "semver";
10
10
  //#region src/rules/prefer-fill-mix.ts
11
11
  /** The first `@tenphi/tasty` release that mixes comma-separated fill colors. */
12
12
  const FILL_MIX_TASTY_VERSION = "3.15.0";
13
+ /**
14
+ * The first `@tenphi/tasty` release with compound colors (`'#surface+#dark.04'`),
15
+ * which replace both two-color fills.
16
+ */
17
+ const COMPOUND_COLOR_TASTY_VERSION = "3.16.0";
13
18
  const FILL_PROPERTIES = new Set(["fill", "backgroundColor"]);
14
19
  /** Color functions Tasty recognizes beyond the ones the parser classifies. */
15
20
  const EXTRA_COLOR_FUNCS = new Set([
@@ -18,6 +23,10 @@ const EXTRA_COLOR_FUNCS = new Set([
18
23
  "okhsl",
19
24
  "okhst"
20
25
  ]);
26
+ /** A color token, optionally faded: one operand of a compound color. */
27
+ const RE_LAYER_OPERAND = /^#[a-z0-9-]+(?:\.(?:\$[a-z_][a-z0-9-_]*|[0-9]+))?$/i;
28
+ /** The weight `#name.$prop` compiles to: `($prop * 100%)`. */
29
+ const RE_PROP_WEIGHT = /^\(\s*(\$[a-z_][a-z0-9-_]*)\s*\*\s*100%\s*\)$/i;
21
30
  /** Whether Tasty's parser takes this token for a color. */
22
31
  function isColor(token) {
23
32
  switch (token.type) {
@@ -36,6 +45,10 @@ function isColor(token) {
36
45
  function isClear(token) {
37
46
  return token.type === "color-token" && (token.raw === "#clear" || token.raw.startsWith("#clear."));
38
47
  }
48
+ /** Whether a color can be the base of a compound: a token, or a compound. */
49
+ function isCompoundBase(color) {
50
+ return color.split("+").every((operand) => RE_LAYER_OPERAND.test(operand));
51
+ }
39
52
  /**
40
53
  * The weight a `#name.N` fade stands for, written the way Tasty compiles the
41
54
  * opacity: `.04` is `4%`, `.5` is `50%`, `.$hover` is `($hover * 100%)`.
@@ -49,10 +62,33 @@ function splitFade(token) {
49
62
  if (token.type !== "color-token" || token.opacity === void 0) return null;
50
63
  return {
51
64
  color: token.raw.slice(0, -(token.opacity.length + 1)),
65
+ opacity: token.opacity,
52
66
  weight: opacityWeight(token.opacity)
53
67
  };
54
68
  }
55
69
  /**
70
+ * The opacity suffix a mix weight stands for, the inverse of
71
+ * {@link opacityWeight}: `4%` is `04`, `4.5%` is `045`, `($hover * 100%)` is
72
+ * `$hover`. Built from the digits, so no float artifact creeps in. `null` when
73
+ * the weight has no suffix, such as a `$prop` holding a percentage, or is not
74
+ * strictly between 0% and 100%.
75
+ */
76
+ function weightToOpacity(weight) {
77
+ if (weight.type === "group-expr") return weight.raw.match(RE_PROP_WEIGHT)?.[1] ?? null;
78
+ if (weight.type !== "css-unit" || weight.unit !== "%") return null;
79
+ if (!(weight.value > 0 && weight.value < 100)) return null;
80
+ const [int, frac = ""] = weight.raw.slice(0, -1).split(".");
81
+ return (int.padStart(2, "0").slice(-2) + frac).replace(/0+$/, "") || null;
82
+ }
83
+ /**
84
+ * `color` painted over `base` at an opacity, as one color: a compound when
85
+ * `base` can be one, and `color-mix()` itself otherwise. Both compile to the
86
+ * same mix, `color-mix(in srgb, <color> <weight>, <base>)`.
87
+ */
88
+ function layerOver(base, fade) {
89
+ return isCompoundBase(base) ? `${base}+${fade.color}.${fade.opacity}` : `color-mix(in srgb, ${fade.color} ${fade.weight}, ${base})`;
90
+ }
91
+ /**
56
92
  * The comma mix that paints what `'<base> <overlay>'` painted, or `null` when
57
93
  * the overlay's alpha is not written on it. Exact when the overlay token holds
58
94
  * an opaque color: drawing it at alpha `a` over the base is the base mixed with
@@ -65,6 +101,38 @@ function rewriteOverlay(base, overlay) {
65
101
  if (fade.weight === "0%") return base;
66
102
  return `${fade.color} ${fade.weight}, ${base}`;
67
103
  }
104
+ /**
105
+ * The compound color that paints what `'<base> <overlay>'` painted, or `null`
106
+ * when the overlay's alpha is not written on it. The same exact rewrite as
107
+ * {@link rewriteOverlay}, a `+` where the space was.
108
+ */
109
+ function rewriteOverlayAsCompound(base, overlay) {
110
+ if (isClear(overlay)) return base;
111
+ const fade = splitFade(overlay);
112
+ if (!fade) return null;
113
+ if (fade.weight === "0%") return base;
114
+ return layerOver(base, fade);
115
+ }
116
+ /**
117
+ * The color a two-part comma mix is, written without the comma. A token with a
118
+ * weight its opacity suffix can hold is layered over the other part, as a
119
+ * compound; any other mix is the same parts in `color-mix()` itself.
120
+ */
121
+ function rewriteMix(parts, groups) {
122
+ for (const [i, tokens] of groups.entries()) {
123
+ const [color, weight] = tokens;
124
+ const base = parts[1 - i];
125
+ if (tokens.length !== 2 || color.type !== "color-token" || color.opacity !== void 0 || color.raw.includes("+") || groups[1 - i].length !== 1 || !isCompoundBase(base)) continue;
126
+ if (isClear(color)) break;
127
+ if (weight.type === "css-unit" && weight.unit === "%") {
128
+ if (weight.value === 0) return base;
129
+ if (weight.value === 100) return color.raw;
130
+ }
131
+ const opacity = weightToOpacity(weight);
132
+ if (opacity !== null) return `${base}+${color.raw}.${opacity}`;
133
+ }
134
+ return `color-mix(in srgb, ${parts.join(", ")})`;
135
+ }
68
136
  /** A token as written. */
69
137
  function tokenText(token) {
70
138
  return "raw" in token ? token.raw : token.value;
@@ -75,12 +143,16 @@ var prefer_fill_mix_default = createRule({
75
143
  type: "suggestion",
76
144
  fixable: "code",
77
145
  hasSuggestions: true,
78
- docs: { description: "Replace deprecated space-separated two-color fills with a comma mix ('#dark 4%, #surface'), and catch mix parts weighted by their opacity" },
146
+ docs: { description: "Replace deprecated two-color fills with a compound color ('#surface+#dark.04'), or with a comma mix before Tasty 3.16, and catch mix parts weighted by their opacity" },
79
147
  messages: {
80
148
  twoColorFill: "Two space-separated colors in '{{property}}' are deprecated: the second is painted as a background-image layer that contrast checkers cannot read. Use '{{suggestion}}'.",
81
- twoColorFillManual: "Two space-separated colors in '{{property}}' are deprecated: the second is painted as a background-image layer that contrast checkers cannot read. Mix an opaque overlay color in by weight ('#overlay 4%, #base'), or use an opaque overlay alone.",
149
+ twoColorFillManual: "Two space-separated colors in '{{property}}' are deprecated: the second is painted as a background-image layer that contrast checkers cannot read. {{advice}}",
82
150
  translucentMixPart: "'{{part}}' is translucent and has no percentage, so its alpha weighs it down on top of its share of the mix and the background comes out see-through. Give it a weight instead: '{{suggestion}}'.",
83
- useWeight: "Use '{{suggestion}}'."
151
+ useWeight: "Use '{{suggestion}}'.",
152
+ commaMix: "Mixing '{{property}}' colors with a comma is deprecated: a compound color or color-mix() is the same color, and works in every color slot. Use '{{suggestion}}'.",
153
+ commaMixManual: "Mixing '{{property}}' colors with a comma is deprecated, and Tasty mixes only the first two. Layer the colors as a compound color ('#base+#overlay.04'), or write color-mix() itself.",
154
+ translucentCommaMix: "Mixing '{{property}}' colors with a comma is deprecated, and '{{part}}' is translucent and has no percentage, so its alpha weighs it down on top of its share of the mix and the background comes out see-through. To paint it over the other color, use '{{suggestion}}'.",
155
+ useCompound: "Use '{{suggestion}}'."
84
156
  },
85
157
  schema: []
86
158
  },
@@ -88,14 +160,19 @@ var prefer_fill_mix_default = createRule({
88
160
  create(context) {
89
161
  const tastyVersion = getInstalledTastyVersion(context.filename);
90
162
  if (!tastyVersion || !semver.satisfies(tastyVersion, `>=${FILL_MIX_TASTY_VERSION}`)) return {};
163
+ const compound = semver.satisfies(tastyVersion, `>=${COMPOUND_COLOR_TASTY_VERSION}`);
91
164
  const ctx = new TastyContext(context);
92
165
  function checkSpaceSeparated(tokens, colorCount, node, property) {
93
- const suggestion = tokens.length === 2 && colorCount === 2 ? rewriteOverlay(tokenText(tokens[0]), tokens[1]) : null;
166
+ const rewrite = compound ? rewriteOverlayAsCompound : rewriteOverlay;
167
+ const suggestion = tokens.length === 2 && colorCount === 2 ? rewrite(tokenText(tokens[0]), tokens[1]) : null;
94
168
  if (suggestion === null) {
95
169
  context.report({
96
170
  node,
97
171
  messageId: "twoColorFillManual",
98
- data: { property }
172
+ data: {
173
+ property,
174
+ advice: compound ? "Layer an opaque overlay color with its opacity ('#base+#overlay.04'), or use an opaque overlay alone." : "Mix an opaque overlay color in by weight ('#overlay 4%, #base'), or use an opaque overlay alone."
175
+ }
99
176
  });
100
177
  return;
101
178
  }
@@ -109,39 +186,73 @@ var prefer_fill_mix_default = createRule({
109
186
  fix: (fixer) => replaceStringValue(fixer, node, suggestion)
110
187
  });
111
188
  }
112
- function checkMix(value, groups, node) {
113
- const parts = splitTopLevelCommas(value).map((part) => part.trim());
189
+ /**
190
+ * The fix for a mix part that is translucent and has no weight, or `null`
191
+ * when neither part is. Each part without a weight takes half the mix, so
192
+ * such a part is weighed down twice, and the result is see-through.
193
+ */
194
+ function translucentPart(parts, groups) {
114
195
  for (const [i, tokens] of groups.entries()) {
115
196
  if (tokens.length !== 1) continue;
116
197
  const [token] = tokens;
117
198
  const other = parts[1 - i];
118
- let replacement = null;
119
- if (isClear(token)) replacement = other;
120
- else {
121
- const fade = splitFade(token);
122
- if (fade) {
123
- const weighted = [...parts];
124
- weighted[i] = `${fade.color} ${fade.weight}`;
125
- replacement = weighted.join(", ");
126
- }
199
+ if (isClear(token)) return {
200
+ part: parts[i],
201
+ suggestion: other
202
+ };
203
+ const fade = splitFade(token);
204
+ if (!fade) continue;
205
+ let suggestion;
206
+ if (compound) {
207
+ if (fade.weight === "0%") return {
208
+ part: parts[i],
209
+ suggestion: other
210
+ };
211
+ suggestion = layerOver(other, fade);
212
+ } else {
213
+ const weighted = [...parts];
214
+ weighted[i] = `${fade.color} ${fade.weight}`;
215
+ suggestion = weighted.join(", ");
127
216
  }
128
- if (replacement === null) continue;
129
- const suggestion = replacement;
217
+ return {
218
+ part: parts[i],
219
+ suggestion
220
+ };
221
+ }
222
+ return null;
223
+ }
224
+ function checkMix(value, groups, node, property) {
225
+ const parts = splitTopLevelCommas(value).map((part) => part.trim());
226
+ const translucent = groups.every((tokens) => tokens.length === 1) ? translucentPart(parts, groups) : null;
227
+ if (translucent) {
228
+ const { part, suggestion } = translucent;
130
229
  context.report({
131
230
  node,
132
- messageId: "translucentMixPart",
231
+ messageId: compound ? "translucentCommaMix" : "translucentMixPart",
133
232
  data: {
134
- part: parts[i],
233
+ property,
234
+ part,
135
235
  suggestion
136
236
  },
137
237
  suggest: [{
138
- messageId: "useWeight",
238
+ messageId: compound ? "useCompound" : "useWeight",
139
239
  data: { suggestion },
140
240
  fix: (fixer) => replaceStringValue(fixer, node, suggestion)
141
241
  }]
142
242
  });
143
243
  return;
144
244
  }
245
+ if (!compound) return;
246
+ const suggestion = rewriteMix(parts, groups);
247
+ context.report({
248
+ node,
249
+ messageId: "commaMix",
250
+ data: {
251
+ property,
252
+ suggestion
253
+ },
254
+ fix: (fixer) => replaceStringValue(fixer, node, suggestion)
255
+ });
145
256
  }
146
257
  function checkValue(value, node, property) {
147
258
  const { groups } = parseValue(value);
@@ -150,7 +261,12 @@ var prefer_fill_mix_default = createRule({
150
261
  if (tokenGroups.length === 1) {
151
262
  const colorCount = tokenGroups[0].filter(isColor).length;
152
263
  if (colorCount >= 2) checkSpaceSeparated(tokenGroups[0], colorCount, node, property);
153
- } else if (tokenGroups.length === 2) checkMix(value, tokenGroups, node);
264
+ } else if (tokenGroups.length === 2) checkMix(value, tokenGroups, node, property);
265
+ else if (compound && tokenGroups.length > 2) context.report({
266
+ node,
267
+ messageId: "commaMixManual",
268
+ data: { property }
269
+ });
154
270
  }
155
271
  return stringStyleValueListeners(ctx, (value, node, property) => {
156
272
  if (FILL_PROPERTIES.has(property)) checkValue(value, node, property);
@@ -1 +1 @@
1
- {"version":3,"file":"prefer-fill-mix.js","names":[],"sources":["../../src/rules/prefer-fill-mix.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport semver from 'semver';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext } from '../context.js';\nimport { getInstalledTastyVersion } from '../config.js';\nimport { replaceStringValue } from '../fix-utils.js';\nimport { parseValue } from '../parsers/index.js';\nimport type { ValueToken } from '../parsers/index.js';\nimport { splitTopLevelCommas } from '../parsers/utils.js';\nimport { COLOR_FUNCS } from '../parsers/value-parser.js';\nimport { stringStyleValueListeners } from '../style-values.js';\n\ntype MessageIds =\n | 'twoColorFill'\n | 'twoColorFillManual'\n | 'translucentMixPart'\n | 'useWeight';\n\n/** The first `@tenphi/tasty` release that mixes comma-separated fill colors. */\nconst FILL_MIX_TASTY_VERSION = '3.15.0';\n\nconst FILL_PROPERTIES = new Set(['fill', 'backgroundColor']);\n\n/** Color functions Tasty recognizes beyond the ones the parser classifies. */\nconst EXTRA_COLOR_FUNCS = new Set([\n 'light-dark',\n 'contrast-color',\n 'okhsl',\n 'okhst',\n]);\n\n/** Whether Tasty's parser takes this token for a color. */\nfunction isColor(token: ValueToken): boolean {\n switch (token.type) {\n case 'color-token':\n case 'color-keyword':\n return true;\n case 'custom-prop':\n // `$name` is a color only when the name ends with `-color`.\n return token.name.endsWith('-color');\n case 'css-function': {\n const name = token.name.toLowerCase();\n return COLOR_FUNCS.has(name) || EXTRA_COLOR_FUNCS.has(name);\n }\n case 'group-expr':\n // A fallback chain such as `(#accent, #purple)`.\n return token.inner.includes('#');\n default:\n return false;\n }\n}\n\n/** `#clear` and its fades compile to `transparent`. */\nfunction isClear(token: ValueToken): boolean {\n return (\n token.type === 'color-token' &&\n (token.raw === '#clear' || token.raw.startsWith('#clear.'))\n );\n}\n\n/**\n * The weight a `#name.N` fade stands for, written the way Tasty compiles the\n * opacity: `.04` is `4%`, `.5` is `50%`, `.$hover` is `($hover * 100%)`.\n */\nfunction opacityWeight(opacity: string): string {\n if (opacity.startsWith('$')) return `(${opacity} * 100%)`;\n return `${Number((Number(`0.${opacity}`) * 100).toFixed(6))}%`;\n}\n\n/** A faded color token split into the color and the weight its opacity is. */\nfunction splitFade(\n token: ValueToken,\n): { color: string; weight: string } | null {\n if (token.type !== 'color-token' || token.opacity === undefined) return null;\n return {\n color: token.raw.slice(0, -(token.opacity.length + 1)),\n weight: opacityWeight(token.opacity),\n };\n}\n\n/**\n * The comma mix that paints what `'<base> <overlay>'` painted, or `null` when\n * the overlay's alpha is not written on it. Exact when the overlay token holds\n * an opaque color: drawing it at alpha `a` over the base is the base mixed with\n * it at weight `a` in sRGB, which is what Tasty compiles the mix to.\n */\nfunction rewriteOverlay(base: string, overlay: ValueToken): string | null {\n if (isClear(overlay)) return base;\n\n const fade = splitFade(overlay);\n if (!fade) return null;\n if (fade.weight === '0%') return base;\n\n return `${fade.color} ${fade.weight}, ${base}`;\n}\n\n/** A token as written. */\nfunction tokenText(token: ValueToken): string {\n return 'raw' in token ? token.raw : token.value;\n}\n\nexport default createRule<[], MessageIds>({\n name: 'prefer-fill-mix',\n meta: {\n type: 'suggestion',\n fixable: 'code',\n hasSuggestions: true,\n docs: {\n description:\n \"Replace deprecated space-separated two-color fills with a comma mix ('#dark 4%, #surface'), and catch mix parts weighted by their opacity\",\n },\n messages: {\n twoColorFill:\n \"Two space-separated colors in '{{property}}' are deprecated: the second is painted as a background-image layer that contrast checkers cannot read. Use '{{suggestion}}'.\",\n twoColorFillManual:\n \"Two space-separated colors in '{{property}}' are deprecated: the second is painted as a background-image layer that contrast checkers cannot read. Mix an opaque overlay color in by weight ('#overlay 4%, #base'), or use an opaque overlay alone.\",\n translucentMixPart:\n \"'{{part}}' is translucent and has no percentage, so its alpha weighs it down on top of its share of the mix and the background comes out see-through. Give it a weight instead: '{{suggestion}}'.\",\n useWeight: \"Use '{{suggestion}}'.\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n // The comma mix is new syntax: rewriting to it for an older Tasty would\n // leave the fill showing only its first color.\n const tastyVersion = getInstalledTastyVersion(context.filename);\n if (\n !tastyVersion ||\n !semver.satisfies(tastyVersion, `>=${FILL_MIX_TASTY_VERSION}`)\n ) {\n return {};\n }\n\n const ctx = new TastyContext(context);\n\n function checkSpaceSeparated(\n tokens: ValueToken[],\n colorCount: number,\n node: TSESTree.Node,\n property: string,\n ): void {\n const suggestion =\n tokens.length === 2 && colorCount === 2\n ? rewriteOverlay(tokenText(tokens[0]), tokens[1])\n : null;\n\n if (suggestion === null) {\n context.report({\n node,\n messageId: 'twoColorFillManual',\n data: { property },\n });\n return;\n }\n\n context.report({\n node,\n messageId: 'twoColorFill',\n data: { property, suggestion },\n fix: (fixer) => replaceStringValue(fixer, node, suggestion),\n });\n }\n\n function checkMix(\n value: string,\n groups: ValueToken[][],\n node: TSESTree.Node,\n ): void {\n const parts = splitTopLevelCommas(value).map((part) => part.trim());\n\n for (const [i, tokens] of groups.entries()) {\n if (tokens.length !== 1) continue;\n\n const [token] = tokens;\n const other = parts[1 - i];\n let replacement: string | null = null;\n\n if (isClear(token)) {\n // A transparent color mixes in nothing.\n replacement = other;\n } else {\n const fade = splitFade(token);\n if (fade) {\n const weighted = [...parts];\n weighted[i] = `${fade.color} ${fade.weight}`;\n replacement = weighted.join(', ');\n }\n }\n\n if (replacement === null) continue;\n\n const suggestion = replacement;\n context.report({\n node,\n messageId: 'translucentMixPart',\n data: { part: parts[i], suggestion },\n suggest: [\n {\n messageId: 'useWeight',\n data: { suggestion },\n fix: (fixer) => replaceStringValue(fixer, node, suggestion),\n },\n ],\n });\n return;\n }\n }\n\n function checkValue(\n value: string,\n node: TSESTree.Node,\n property: string,\n ): void {\n const { groups } = parseValue(value);\n\n // Slash-separated parts mean nothing to `fill`; leave such values alone.\n if (groups.some((group) => group.parts.length !== 1)) return;\n const tokenGroups = groups.map((group) => group.parts[0].tokens);\n\n if (tokenGroups.length === 1) {\n const colorCount = tokenGroups[0].filter(isColor).length;\n if (colorCount >= 2) {\n checkSpaceSeparated(tokenGroups[0], colorCount, node, property);\n }\n } else if (tokenGroups.length === 2) {\n checkMix(value, tokenGroups, node);\n }\n }\n\n return stringStyleValueListeners(\n ctx,\n (value, node, property) => {\n if (FILL_PROPERTIES.has(property)) checkValue(value, node, property);\n },\n { requireTastyJSX: true },\n );\n },\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"prefer-fill-mix.js","names":[],"sources":["../../src/rules/prefer-fill-mix.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport semver from 'semver';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext } from '../context.js';\nimport { getInstalledTastyVersion } from '../config.js';\nimport { replaceStringValue } from '../fix-utils.js';\nimport { parseValue } from '../parsers/index.js';\nimport type { ValueToken } from '../parsers/index.js';\nimport { splitTopLevelCommas } from '../parsers/utils.js';\nimport { COLOR_FUNCS } from '../parsers/value-parser.js';\nimport { stringStyleValueListeners } from '../style-values.js';\n\ntype MessageIds =\n | 'twoColorFill'\n | 'twoColorFillManual'\n | 'translucentMixPart'\n | 'useWeight'\n | 'commaMix'\n | 'commaMixManual'\n | 'translucentCommaMix'\n | 'useCompound';\n\n/** The first `@tenphi/tasty` release that mixes comma-separated fill colors. */\nconst FILL_MIX_TASTY_VERSION = '3.15.0';\n\n/**\n * The first `@tenphi/tasty` release with compound colors (`'#surface+#dark.04'`),\n * which replace both two-color fills.\n */\nconst COMPOUND_COLOR_TASTY_VERSION = '3.16.0';\n\nconst FILL_PROPERTIES = new Set(['fill', 'backgroundColor']);\n\n/** Color functions Tasty recognizes beyond the ones the parser classifies. */\nconst EXTRA_COLOR_FUNCS = new Set([\n 'light-dark',\n 'contrast-color',\n 'okhsl',\n 'okhst',\n]);\n\n/** A color token, optionally faded: one operand of a compound color. */\nconst RE_LAYER_OPERAND = /^#[a-z0-9-]+(?:\\.(?:\\$[a-z_][a-z0-9-_]*|[0-9]+))?$/i;\n\n/** The weight `#name.$prop` compiles to: `($prop * 100%)`. */\nconst RE_PROP_WEIGHT = /^\\(\\s*(\\$[a-z_][a-z0-9-_]*)\\s*\\*\\s*100%\\s*\\)$/i;\n\n/** Whether Tasty's parser takes this token for a color. */\nfunction isColor(token: ValueToken): boolean {\n switch (token.type) {\n case 'color-token':\n case 'color-keyword':\n return true;\n case 'custom-prop':\n // `$name` is a color only when the name ends with `-color`.\n return token.name.endsWith('-color');\n case 'css-function': {\n const name = token.name.toLowerCase();\n return COLOR_FUNCS.has(name) || EXTRA_COLOR_FUNCS.has(name);\n }\n case 'group-expr':\n // A fallback chain such as `(#accent, #purple)`.\n return token.inner.includes('#');\n default:\n return false;\n }\n}\n\n/** `#clear` and its fades compile to `transparent`. */\nfunction isClear(token: ValueToken): boolean {\n return (\n token.type === 'color-token' &&\n (token.raw === '#clear' || token.raw.startsWith('#clear.'))\n );\n}\n\n/** Whether a color can be the base of a compound: a token, or a compound. */\nfunction isCompoundBase(color: string): boolean {\n return color.split('+').every((operand) => RE_LAYER_OPERAND.test(operand));\n}\n\n/**\n * The weight a `#name.N` fade stands for, written the way Tasty compiles the\n * opacity: `.04` is `4%`, `.5` is `50%`, `.$hover` is `($hover * 100%)`.\n */\nfunction opacityWeight(opacity: string): string {\n if (opacity.startsWith('$')) return `(${opacity} * 100%)`;\n return `${Number((Number(`0.${opacity}`) * 100).toFixed(6))}%`;\n}\n\n/** A faded color token split into the color and the weight its opacity is. */\nfunction splitFade(\n token: ValueToken,\n): { color: string; opacity: string; weight: string } | null {\n if (token.type !== 'color-token' || token.opacity === undefined) return null;\n return {\n color: token.raw.slice(0, -(token.opacity.length + 1)),\n opacity: token.opacity,\n weight: opacityWeight(token.opacity),\n };\n}\n\n/**\n * The opacity suffix a mix weight stands for, the inverse of\n * {@link opacityWeight}: `4%` is `04`, `4.5%` is `045`, `($hover * 100%)` is\n * `$hover`. Built from the digits, so no float artifact creeps in. `null` when\n * the weight has no suffix, such as a `$prop` holding a percentage, or is not\n * strictly between 0% and 100%.\n */\nfunction weightToOpacity(weight: ValueToken): string | null {\n if (weight.type === 'group-expr') {\n return weight.raw.match(RE_PROP_WEIGHT)?.[1] ?? null;\n }\n if (weight.type !== 'css-unit' || weight.unit !== '%') return null;\n if (!(weight.value > 0 && weight.value < 100)) return null;\n\n const [int, frac = ''] = weight.raw.slice(0, -1).split('.');\n const digits = (int.padStart(2, '0').slice(-2) + frac).replace(/0+$/, '');\n return digits || null;\n}\n\n/**\n * `color` painted over `base` at an opacity, as one color: a compound when\n * `base` can be one, and `color-mix()` itself otherwise. Both compile to the\n * same mix, `color-mix(in srgb, <color> <weight>, <base>)`.\n */\nfunction layerOver(\n base: string,\n fade: { color: string; opacity: string; weight: string },\n): string {\n return isCompoundBase(base)\n ? `${base}+${fade.color}.${fade.opacity}`\n : `color-mix(in srgb, ${fade.color} ${fade.weight}, ${base})`;\n}\n\n/**\n * The comma mix that paints what `'<base> <overlay>'` painted, or `null` when\n * the overlay's alpha is not written on it. Exact when the overlay token holds\n * an opaque color: drawing it at alpha `a` over the base is the base mixed with\n * it at weight `a` in sRGB, which is what Tasty compiles the mix to.\n */\nfunction rewriteOverlay(base: string, overlay: ValueToken): string | null {\n if (isClear(overlay)) return base;\n\n const fade = splitFade(overlay);\n if (!fade) return null;\n if (fade.weight === '0%') return base;\n\n return `${fade.color} ${fade.weight}, ${base}`;\n}\n\n/**\n * The compound color that paints what `'<base> <overlay>'` painted, or `null`\n * when the overlay's alpha is not written on it. The same exact rewrite as\n * {@link rewriteOverlay}, a `+` where the space was.\n */\nfunction rewriteOverlayAsCompound(\n base: string,\n overlay: ValueToken,\n): string | null {\n if (isClear(overlay)) return base;\n\n const fade = splitFade(overlay);\n if (!fade) return null;\n if (fade.weight === '0%') return base;\n\n return layerOver(base, fade);\n}\n\n/**\n * The color a two-part comma mix is, written without the comma. A token with a\n * weight its opacity suffix can hold is layered over the other part, as a\n * compound; any other mix is the same parts in `color-mix()` itself.\n */\nfunction rewriteMix(parts: string[], groups: ValueToken[][]): string {\n for (const [i, tokens] of groups.entries()) {\n const [color, weight] = tokens;\n const base = parts[1 - i];\n if (\n tokens.length !== 2 ||\n color.type !== 'color-token' ||\n color.opacity !== undefined ||\n color.raw.includes('+') ||\n groups[1 - i].length !== 1 ||\n !isCompoundBase(base)\n ) {\n continue;\n }\n\n // A compound leaves out a transparent layer, while the mix lets it thin\n // out the other color: `#clear 10%, #surface` is 90% opaque.\n if (isClear(color)) break;\n\n // A weight of nothing or of everything leaves one of the colors.\n if (weight.type === 'css-unit' && weight.unit === '%') {\n if (weight.value === 0) return base;\n if (weight.value === 100) return color.raw;\n }\n\n const opacity = weightToOpacity(weight);\n if (opacity !== null) return `${base}+${color.raw}.${opacity}`;\n }\n\n return `color-mix(in srgb, ${parts.join(', ')})`;\n}\n\n/** A token as written. */\nfunction tokenText(token: ValueToken): string {\n return 'raw' in token ? token.raw : token.value;\n}\n\nexport default createRule<[], MessageIds>({\n name: 'prefer-fill-mix',\n meta: {\n type: 'suggestion',\n fixable: 'code',\n hasSuggestions: true,\n docs: {\n description:\n \"Replace deprecated two-color fills with a compound color ('#surface+#dark.04'), or with a comma mix before Tasty 3.16, and catch mix parts weighted by their opacity\",\n },\n messages: {\n twoColorFill:\n \"Two space-separated colors in '{{property}}' are deprecated: the second is painted as a background-image layer that contrast checkers cannot read. Use '{{suggestion}}'.\",\n twoColorFillManual:\n \"Two space-separated colors in '{{property}}' are deprecated: the second is painted as a background-image layer that contrast checkers cannot read. {{advice}}\",\n translucentMixPart:\n \"'{{part}}' is translucent and has no percentage, so its alpha weighs it down on top of its share of the mix and the background comes out see-through. Give it a weight instead: '{{suggestion}}'.\",\n useWeight: \"Use '{{suggestion}}'.\",\n commaMix:\n \"Mixing '{{property}}' colors with a comma is deprecated: a compound color or color-mix() is the same color, and works in every color slot. Use '{{suggestion}}'.\",\n commaMixManual:\n \"Mixing '{{property}}' colors with a comma is deprecated, and Tasty mixes only the first two. Layer the colors as a compound color ('#base+#overlay.04'), or write color-mix() itself.\",\n translucentCommaMix:\n \"Mixing '{{property}}' colors with a comma is deprecated, and '{{part}}' is translucent and has no percentage, so its alpha weighs it down on top of its share of the mix and the background comes out see-through. To paint it over the other color, use '{{suggestion}}'.\",\n useCompound: \"Use '{{suggestion}}'.\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n // The comma mix and compound colors are new syntax: rewriting to them for\n // an older Tasty would leave the fill showing only its first color.\n const tastyVersion = getInstalledTastyVersion(context.filename);\n if (\n !tastyVersion ||\n !semver.satisfies(tastyVersion, `>=${FILL_MIX_TASTY_VERSION}`)\n ) {\n return {};\n }\n const compound = semver.satisfies(\n tastyVersion,\n `>=${COMPOUND_COLOR_TASTY_VERSION}`,\n );\n\n const ctx = new TastyContext(context);\n\n function checkSpaceSeparated(\n tokens: ValueToken[],\n colorCount: number,\n node: TSESTree.Node,\n property: string,\n ): void {\n const rewrite = compound ? rewriteOverlayAsCompound : rewriteOverlay;\n const suggestion =\n tokens.length === 2 && colorCount === 2\n ? rewrite(tokenText(tokens[0]), tokens[1])\n : null;\n\n if (suggestion === null) {\n context.report({\n node,\n messageId: 'twoColorFillManual',\n data: {\n property,\n advice: compound\n ? \"Layer an opaque overlay color with its opacity ('#base+#overlay.04'), or use an opaque overlay alone.\"\n : \"Mix an opaque overlay color in by weight ('#overlay 4%, #base'), or use an opaque overlay alone.\",\n },\n });\n return;\n }\n\n context.report({\n node,\n messageId: 'twoColorFill',\n data: { property, suggestion },\n fix: (fixer) => replaceStringValue(fixer, node, suggestion),\n });\n }\n\n /**\n * The fix for a mix part that is translucent and has no weight, or `null`\n * when neither part is. Each part without a weight takes half the mix, so\n * such a part is weighed down twice, and the result is see-through.\n */\n function translucentPart(\n parts: string[],\n groups: ValueToken[][],\n ): { part: string; suggestion: string } | null {\n for (const [i, tokens] of groups.entries()) {\n if (tokens.length !== 1) continue;\n\n const [token] = tokens;\n const other = parts[1 - i];\n\n // A transparent color mixes in nothing.\n if (isClear(token)) return { part: parts[i], suggestion: other };\n\n const fade = splitFade(token);\n if (!fade) continue;\n\n let suggestion: string;\n if (compound) {\n if (fade.weight === '0%')\n return { part: parts[i], suggestion: other };\n suggestion = layerOver(other, fade);\n } else {\n const weighted = [...parts];\n weighted[i] = `${fade.color} ${fade.weight}`;\n suggestion = weighted.join(', ');\n }\n return { part: parts[i], suggestion };\n }\n\n return null;\n }\n\n function checkMix(\n value: string,\n groups: ValueToken[][],\n node: TSESTree.Node,\n property: string,\n ): void {\n const parts = splitTopLevelCommas(value).map((part) => part.trim());\n\n // Opposite a weighted part, a faded one is only the base of a layer.\n const translucent = groups.every((tokens) => tokens.length === 1)\n ? translucentPart(parts, groups)\n : null;\n\n // A rewrite here changes what renders, so it is only suggested.\n if (translucent) {\n const { part, suggestion } = translucent;\n context.report({\n node,\n messageId: compound ? 'translucentCommaMix' : 'translucentMixPart',\n data: { property, part, suggestion },\n suggest: [\n {\n messageId: compound ? 'useCompound' : 'useWeight',\n data: { suggestion },\n fix: (fixer) => replaceStringValue(fixer, node, suggestion),\n },\n ],\n });\n return;\n }\n\n if (!compound) return;\n\n const suggestion = rewriteMix(parts, groups);\n context.report({\n node,\n messageId: 'commaMix',\n data: { property, suggestion },\n fix: (fixer) => replaceStringValue(fixer, node, suggestion),\n });\n }\n\n function checkValue(\n value: string,\n node: TSESTree.Node,\n property: string,\n ): void {\n const { groups } = parseValue(value);\n\n // Slash-separated parts mean nothing to `fill`; leave such values alone.\n if (groups.some((group) => group.parts.length !== 1)) return;\n const tokenGroups = groups.map((group) => group.parts[0].tokens);\n\n if (tokenGroups.length === 1) {\n const colorCount = tokenGroups[0].filter(isColor).length;\n if (colorCount >= 2) {\n checkSpaceSeparated(tokenGroups[0], colorCount, node, property);\n }\n } else if (tokenGroups.length === 2) {\n checkMix(value, tokenGroups, node, property);\n } else if (compound && tokenGroups.length > 2) {\n context.report({\n node,\n messageId: 'commaMixManual',\n data: { property },\n });\n }\n }\n\n return stringStyleValueListeners(\n ctx,\n (value, node, property) => {\n if (FILL_PROPERTIES.has(property)) checkValue(value, node, property);\n },\n { requireTastyJSX: true },\n );\n },\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/eslint-plugin-tasty",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "ESLint plugin for validating tasty() and tastyStatic() style objects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,9 +51,9 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@changesets/changelog-github": "^0.5.2",
54
- "@changesets/cli": "^2.29.8",
54
+ "@changesets/cli": "^2.31.1",
55
55
  "@eslint/js": "^10.0.1",
56
- "@tenphi/tasty": "^3.14.0",
56
+ "@tenphi/tasty": "^3.17.1",
57
57
  "@types/node": "^22.0.0",
58
58
  "@types/semver": "^7.8.0",
59
59
  "@typescript-eslint/parser": "^8.56.1",