mirror of
https://github.com/antebudimir/eslint-plugin-vanilla-extract.git
synced 2025-12-31 08:53:33 +00:00
feat 🥁: add prefer-theme-tokens rule
- Enforce theme tokens over hard-coded values in vanilla-extract styles (colors, spacing, font sizes, border radius/widths, shadows, z-index, opacity, font weights, transitions) - Provide token suggestions from configured theme contracts; optional auto-fix for unambiguous replacements
This commit is contained in:
parent
d5eae5dfc8
commit
1d88c12e3d
16 changed files with 3201 additions and 21 deletions
|
|
@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.15.0] - 2025-11-14
|
||||
|
||||
- Add new rule `prefer-theme-tokens`that requires theme tokens instead of hard-coded values in vanilla-extract styles
|
||||
- Detects hard-coded values across colors, spacing, font sizes, border radius/widths, shadows, z-index, opacity, font weights, and transitions
|
||||
- Provides suggestions from configured theme contracts; optional auto-fix for unambiguous replacements
|
||||
- Supports nested objects, media queries, selectors, and (optionally) template literals/helper calls
|
||||
- Configurable via `themeContracts`, category toggles, `allowedValues`, `allowedProperties`, `autoFix`, `remBase`, `checkHelperFunctions` (see README for details)
|
||||
|
||||
## [1.14.0] - 2025-11-09
|
||||
|
||||
- Add new rule `prefer-logical-properties` that enforces logical CSS properties over physical directional properties
|
||||
|
|
|
|||
112
README.md
112
README.md
|
|
@ -265,6 +265,7 @@ The recommended configuration enables the following rules with error severity:
|
|||
- `vanilla-extract/custom-order`: Alternative ordering rule (custom group-based sorting)
|
||||
- `vanilla-extract/no-px-unit`: Disallows px units with an optional allowlist
|
||||
- `vanilla-extract/prefer-logical-properties`: Enforces logical CSS properties over physical directional properties
|
||||
- `vanilla-extract/prefer-theme-tokens`: Enforces theme tokens instead of hard-coded values for colors, spacing, font sizes, border radius, border widths, shadows, z-index, opacity, font weights, and transitions/animations (optionally evaluates helper functions and template literals)
|
||||
|
||||
You can use the recommended configuration as a starting point and override rules as needed for your project. See the configuration examples above for how to switch between ordering rules.
|
||||
|
||||
|
|
@ -639,6 +640,113 @@ export const box = style({
|
|||
borderInlineEnd: '1px solid',
|
||||
textAlign: 'start',
|
||||
});
|
||||
```
|
||||
|
||||
### vanilla-extract/prefer-theme-tokens
|
||||
|
||||
Enforces theme tokens instead of hard-coded CSS values. Analyzes your theme contract files and suggests **specific tokens** when matches are found.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `themeContracts` | `string[]` | `[]` | Theme contract file paths (relative to project root or absolute) |
|
||||
| `checkColors` | `boolean` | `true` | Check colors (hex, rgb, hsl, named) |
|
||||
| `checkSpacing` | `boolean` | `true` | Check spacing (margin, padding, gap, width, height) |
|
||||
| `checkFontSizes` | `boolean` | `true` | Check font sizes (fontSize, lineHeight) |
|
||||
| `checkBorderRadius` | `boolean` | `true` | Check border radius values |
|
||||
| `checkBorderWidths` | `boolean` | `true` | Check border widths (including `border` shorthand) |
|
||||
| `checkShadows` | `boolean` | `true` | Check shadows (boxShadow, textShadow, filter) |
|
||||
| `checkZIndex` | `boolean` | `true` | Check z-index values |
|
||||
| `checkOpacity` | `boolean` | `true` | Check opacity values |
|
||||
| `checkFontWeights` | `boolean` | `true` | Check font weights (numeric and named) |
|
||||
| `checkTransitions` | `boolean` | `true` | Check transitions and animations |
|
||||
| `allowedValues` | `string[]` | `[]` | Whitelist specific values (e.g., `["0", "auto", "100vh"]`) |
|
||||
| `allowedProperties` | `string[]` | `[]` | Skip checking specific properties |
|
||||
| `autoFix` | `boolean` | `false` | Auto-fix when exactly one token matches |
|
||||
| `remBase` | `number` | `16` | Base font size for `rem()` calculations |
|
||||
| `checkHelperFunctions` | `boolean` | `false` | Check helper calls like `rem(48)`, `` `${rem(4)}` `` |
|
||||
|
||||
#### Dependency note
|
||||
|
||||
This rule uses a safe expression evaluator to optionally analyze helper calls when `checkHelperFunctions` is enabled. For this, the plugin internally relies on `@babel/parser` and `@babel/types` to parse small expression snippets (e.g., template literals, `rem()` calls). These are shipped as plugin dependencies, so users don't need to install them manually. They're only exercised when `checkHelperFunctions` is turned on.
|
||||
|
||||
**Example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": {
|
||||
"vanilla-extract/prefer-theme-tokens": ["error", {
|
||||
"themeContracts": ["./src/theme.css.ts"],
|
||||
"checkColors": true,
|
||||
"checkSpacing": true,
|
||||
"allowedValues": ["0", "auto", "100%"],
|
||||
"allowedProperties": ["borderWidth"],
|
||||
"autoFix": false,
|
||||
"checkHelperFunctions": false
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. **Analyzes theme contracts** - Reads your theme files and evaluates computed values:
|
||||
- `rem(16)` → `"1rem"`
|
||||
- `` `${rem(4)} ${rem(8)}` `` → `"0.25rem 0.5rem"`
|
||||
- Arithmetic expressions
|
||||
|
||||
2. **Detects hard-coded values** - Checks literals, numbers, and (optionally) helper functions:
|
||||
|
||||
```typescript
|
||||
color: '#0055FF' // ❌ Always flagged
|
||||
padding: '16px' // ❌ Always flagged
|
||||
opacity: 0.5 // ❌ Always flagged (numeric literal)
|
||||
margin: rem(48) // ❌ Only with checkHelperFunctions: true
|
||||
boxShadow: `${rem(4)}...` // ❌ Only with checkHelperFunctions: true
|
||||
```
|
||||
|
||||
3. **Suggests specific tokens** - Matches values to theme tokens:
|
||||
|
||||
```text
|
||||
❌ Hard-coded color '#0055FF'. Use theme token: vars.colors.brand
|
||||
❌ Hard-coded padding '16px'. Use theme token: vars.spacing.md
|
||||
```
|
||||
|
||||
- **Single match**: Shows one suggestion + auto-fix (if enabled)
|
||||
- **Multiple matches**: Shows all as quick-fix options
|
||||
|
||||
**Theme contract example:**
|
||||
|
||||
```typescript
|
||||
// theme.css.ts
|
||||
export const [themeClass, vars] = createTheme({
|
||||
colors: { brand: '#0055FF', text: '#1f2937' },
|
||||
spacing: { sm: '8px', md: '16px' },
|
||||
});
|
||||
|
||||
// styles.css.ts
|
||||
export const button = style({
|
||||
backgroundColor: '#0055FF', // ❌ Use vars.colors.brand
|
||||
padding: '8px', // ❌ Use vars.spacing.sm
|
||||
});
|
||||
```
|
||||
|
||||
**Helper function detection:**
|
||||
|
||||
By default, only checks **literals**. Enable `checkHelperFunctions: true` to also check computed values:
|
||||
|
||||
```typescript
|
||||
// checkHelperFunctions: false (default)
|
||||
padding: rem(48) // ✅ Not flagged
|
||||
padding: '3rem' // ❌ Flagged
|
||||
|
||||
// checkHelperFunctions: true
|
||||
padding: rem(48) // ❌ Flagged if theme has matching token
|
||||
padding: '3rem' // ❌ Flagged if theme has matching token
|
||||
```
|
||||
|
||||
**Note:** Opt-in rule (not in recommended config). Enable when ready to enforce design tokens.
|
||||
|
||||
## Font Face Declarations
|
||||
|
||||
|
|
@ -726,14 +834,14 @@ The roadmap outlines the project's current status and future plans:
|
|||
- Comprehensive rule testing.
|
||||
- `no-px-unit` rule to disallow use of `px` units with configurable whitelist.
|
||||
- `prefer-logical-properties` rule to enforce use of logical properties.
|
||||
- `prefer-theme-tokens` rule to enforce theme tokens instead of hard-coded values for colors, spacing, font sizes, border radius, border widths, shadows, z-index, opacity, font weights, and transitions/animations (optionally evaluates helper functions and template literals).
|
||||
|
||||
### Current Work
|
||||
|
||||
- `prefer-theme-tokens` rule to enforce use of theme tokens instead of hard-coded values when available.
|
||||
- `no-unitless-values` rule that disallows numeric literals for CSS properties that are not unitless in CSS.
|
||||
|
||||
### Upcoming Features
|
||||
|
||||
- `no-unitless-values` rule that disallows numeric literals for CSS properties that are not unitless in CSS.
|
||||
- `property-unit-match` rule to enforce valid units per CSS property specs. **Note**: This feature will only be
|
||||
implemented if there's sufficient interest from the community.
|
||||
- Option to sort properties within user-defined concentric groups alphabetically instead of following the concentric
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@antebudimir/eslint-plugin-vanilla-extract",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"description": "Comprehensive ESLint plugin for vanilla-extract with CSS property ordering, style validation, and best practices enforcement. Supports alphabetical, concentric and custom CSS ordering, auto-fixing, and zero-runtime safety.",
|
||||
"author": "Ante Budimir",
|
||||
"license": "MIT",
|
||||
|
|
@ -65,6 +65,10 @@
|
|||
"peerDependencies": {
|
||||
"eslint": ">=8.57.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@babel/types": "^7.28.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.0",
|
||||
"@types/node": "^20.17.24",
|
||||
|
|
|
|||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
|
|
@ -7,6 +7,13 @@ settings:
|
|||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@babel/parser':
|
||||
specifier: ^7.28.5
|
||||
version: 7.28.5
|
||||
'@babel/types':
|
||||
specifier: ^7.28.5
|
||||
version: 7.28.5
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.0
|
||||
|
|
@ -75,16 +82,16 @@ packages:
|
|||
'@antfu/utils@8.1.1':
|
||||
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||
|
||||
'@babel/helper-string-parser@7.25.9':
|
||||
resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.25.9':
|
||||
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.26.10':
|
||||
resolution: {integrity: sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==}
|
||||
'@babel/parser@7.28.5':
|
||||
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -92,8 +99,8 @@ packages:
|
|||
resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.26.10':
|
||||
resolution: {integrity: sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==}
|
||||
'@babel/types@7.28.5':
|
||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
|
|
@ -1862,22 +1869,22 @@ snapshots:
|
|||
|
||||
'@antfu/utils@8.1.1': {}
|
||||
|
||||
'@babel/helper-string-parser@7.25.9': {}
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.25.9': {}
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/parser@7.26.10':
|
||||
'@babel/parser@7.28.5':
|
||||
dependencies:
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@babel/runtime@7.26.10':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/types@7.26.10':
|
||||
'@babel/types@7.28.5':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
|
|
@ -3200,8 +3207,8 @@ snapshots:
|
|||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.10
|
||||
'@babel/types': 7.26.10
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,912 @@
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import { run } from 'eslint-vitest-rule-tester';
|
||||
import rule from '../rule-definition.js';
|
||||
|
||||
const valids = [
|
||||
// Using theme tokens - should pass
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { vars } from './test-theme.css';
|
||||
const myStyle = style({
|
||||
color: vars.colors.brand,
|
||||
backgroundColor: vars.colors.background,
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
// Allowed keywords
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: 'transparent',
|
||||
backgroundColor: 'currentcolor',
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
// When checks are disabled
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: '#ff0000',
|
||||
});
|
||||
`,
|
||||
options: [{ checkColors: false }],
|
||||
},
|
||||
|
||||
// Allowed values option
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0',
|
||||
padding: 'auto',
|
||||
width: '100%',
|
||||
});
|
||||
`,
|
||||
},
|
||||
|
||||
// Allowed properties option
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderWidth: '1px',
|
||||
});
|
||||
`,
|
||||
options: [{ allowedProperties: ['borderWidth'] }],
|
||||
},
|
||||
|
||||
// Helper functions are NOT flagged by default (checkHelperFunctions: false)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
padding: rem(16),
|
||||
margin: rem(8),
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: ['./test-theme.css.ts'] }],
|
||||
},
|
||||
|
||||
// Checks disabled for new categories
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderWidth: '2px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
zIndex: 10,
|
||||
opacity: 0.5,
|
||||
fontWeight: 700,
|
||||
transition: '0.3s ease',
|
||||
});
|
||||
`,
|
||||
options: [{
|
||||
checkBorderWidths: false,
|
||||
checkShadows: false,
|
||||
checkZIndex: false,
|
||||
checkOpacity: false,
|
||||
checkFontWeights: false,
|
||||
checkTransitions: false,
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// Resolve absolute path to the local test theme contracts
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const themeAbs = path.resolve(__dirname, './test-theme.css.ts');
|
||||
const themeWithRemAbs = path.resolve(__dirname, './test-theme-with-rem.css.ts');
|
||||
|
||||
const invalids = [
|
||||
// Hard-coded color with exact theme match via absolute themeContracts path
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: '#0055FF',
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeAbs] }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Hard-coded spacing with exact theme match via absolute themeContracts path
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '8px',
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeAbs] }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Hard-coded color without theme contract
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: '#0055FF',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Hard-coded spacing
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '8px',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Hard-coded font size
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
fontSize: '16px',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Hard-coded border radius
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderRadius: '4px',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// RGB color
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: 'rgb(255, 0, 0)',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Named color
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: 'red',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Multiple hard-coded values
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: '#0055FF',
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '8px',
|
||||
fontSize: '16px',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Nested structures (media queries, selectors)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
'@media': {
|
||||
'(min-width: 768px)': {
|
||||
color: '#0055FF',
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Recipe with hard-coded values
|
||||
{
|
||||
code: `
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
const button = recipe({
|
||||
base: {
|
||||
color: '#0055FF',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
sm: { fontSize: '12px' },
|
||||
lg: { fontSize: '20px' },
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// globalStyle with hard-coded values
|
||||
{
|
||||
code: `
|
||||
import { globalStyle } from '@vanilla-extract/css';
|
||||
globalStyle('body', {
|
||||
color: '#1f2937',
|
||||
margin: '0px',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
{
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Test rem() evaluation - spacing
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
margin: '0.5rem',
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeWithRemAbs] }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Test rem() evaluation - fontSize
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
fontSize: '1rem',
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeWithRemAbs] }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Test rem() evaluation - borderRadius
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderRadius: '0.25rem',
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeWithRemAbs] }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Test color matching with rem theme
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
backgroundColor: '#5614b8',
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeWithRemAbs] }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Test RGB color matching with rem theme
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeWithRemAbs] }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Test helper function detection with checkHelperFunctions: true
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
padding: rem(16),
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeWithRemAbs], checkHelperFunctions: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
data: {
|
||||
value: '1rem',
|
||||
property: 'padding',
|
||||
tokenPath: 'lightTheme.spacing.medium',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Test helper function with multiple matches
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
fontSize: rem(16),
|
||||
});
|
||||
`,
|
||||
options: [{ themeContracts: [themeWithRemAbs], checkHelperFunctions: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Border widths - string literal
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderWidth: '2px',
|
||||
borderTopWidth: '1px',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Border shorthand
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
border: '1px solid red',
|
||||
borderTop: '2px dashed blue',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Shadows - boxShadow and textShadow
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
textShadow: '1px 1px 2px black',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Z-index - numeric literal
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
zIndex: 10,
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Z-index - string literal
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
zIndex: '100',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Opacity - numeric literal
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
opacity: 0.5,
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Opacity - string literal
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
opacity: '0.8',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Font weight - numeric literal
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
fontWeight: 700,
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Font weight - string literal (named)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
fontWeight: 'bold',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Transitions - duration
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
transition: '0.3s ease-in-out',
|
||||
transitionDuration: '200ms',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Animation
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
animation: '1s ease-in',
|
||||
animationDuration: '500ms',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Template literal with helper functions (checkHelperFunctions: true)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
boxShadow: \`\${rem(4)} \${rem(8)} \${rem(16)} rgba(0,0,0,0.14)\`,
|
||||
});
|
||||
`,
|
||||
options: [{ checkHelperFunctions: true }],
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Multiple new categories together
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderWidth: '1px',
|
||||
boxShadow: '0 2px 4px black',
|
||||
zIndex: 999,
|
||||
opacity: 0.75,
|
||||
fontWeight: 600,
|
||||
transition: '0.2s linear',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// HSL color
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
color: 'hsl(200, 50%, 50%)',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// RGBA color
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
backgroundColor: 'rgba(255, 0, 0, 0.5)',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Filter property (shadow category)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
filter: 'blur(10px)',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Numeric literals for all new categories
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
zIndex: 5,
|
||||
opacity: 1,
|
||||
fontWeight: 400,
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// fontFace with hard-coded values
|
||||
{
|
||||
code: `
|
||||
import { fontFace } from '@vanilla-extract/css';
|
||||
const myFont = fontFace({
|
||||
fontWeight: 700,
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// All new categories without theme contract (to test getCategoryName paths)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderWidth: '3px',
|
||||
borderTopWidth: '2px',
|
||||
boxShadow: '0 0 10px rgba(0,0,0,0.5)',
|
||||
textShadow: '2px 2px 4px black',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.2))',
|
||||
zIndex: 50,
|
||||
opacity: 0.9,
|
||||
fontWeight: 500,
|
||||
transition: '0.5s cubic-bezier(0.25,0.1,0.25,1)',
|
||||
animation: '2s ease-out',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// String variants of numeric categories
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
zIndex: '25',
|
||||
opacity: '0.3',
|
||||
fontWeight: '300',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Edge case: named font weights
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
fontWeight: 'bolder',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Edge case: various transition timing functions
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
transitionTimingFunction: 'ease',
|
||||
animationTimingFunction: 'linear',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Border shorthand variants
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
borderTop: '3px dotted green',
|
||||
borderRight: '1px solid black',
|
||||
borderBottom: '2px dashed blue',
|
||||
borderLeft: '4px double red',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Outline (also a border-width category property)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
const myStyle = style({
|
||||
outline: '2px solid red',
|
||||
outlineWidth: '3px',
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Template literals for new categories (checkHelperFunctions: true)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
borderWidth: \`\${rem(2)}\`,
|
||||
boxShadow: \`\${rem(0)} \${rem(4)} \${rem(8)} rgba(0,0,0,0.2)\`,
|
||||
fontWeight: \`700\`,
|
||||
transition: \`\${0.3}s ease\`,
|
||||
});
|
||||
`,
|
||||
options: [{ checkHelperFunctions: true }],
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// CallExpression for new categories (checkHelperFunctions: true)
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
borderWidth: rem(2),
|
||||
borderRadius: rem(8),
|
||||
});
|
||||
`,
|
||||
options: [{ checkHelperFunctions: true }],
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// Template literals for spacing, fontSize, borderRadius with helpers
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
margin: \`\${rem(16)}\`,
|
||||
fontSize: \`\${rem(14)}\`,
|
||||
borderRadius: \`\${rem(4)}\`,
|
||||
});
|
||||
`,
|
||||
options: [{ checkHelperFunctions: true }],
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
|
||||
// CallExpression for all main categories
|
||||
{
|
||||
code: `
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { rem } from 'polished';
|
||||
const myStyle = style({
|
||||
padding: rem(12),
|
||||
fontSize: rem(16),
|
||||
borderRadius: rem(8),
|
||||
borderWidth: rem(1),
|
||||
});
|
||||
`,
|
||||
options: [{ checkHelperFunctions: true }],
|
||||
errors: [
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
{ messageId: 'hardCodedValueNoContract' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
run({
|
||||
name: 'vanilla-extract/prefer-theme-tokens',
|
||||
rule: rule,
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
valid: valids,
|
||||
invalid: invalids,
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { createTheme } from '@vanilla-extract/css';
|
||||
|
||||
// Mock rem function for testing
|
||||
const rem = (px: number) => `${px / 16}rem`;
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
spacing: {
|
||||
small: rem(8),
|
||||
medium: rem(16),
|
||||
large: rem(32),
|
||||
},
|
||||
fontSize: {
|
||||
small: rem(12),
|
||||
medium: rem(16),
|
||||
},
|
||||
borderRadius: {
|
||||
small: rem(4),
|
||||
medium: rem(8),
|
||||
},
|
||||
color: {
|
||||
brand: '#5614b8',
|
||||
white: 'rgb(255, 255, 255)',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { createTheme } from '@vanilla-extract/css';
|
||||
|
||||
export const [themeClass, vars] = createTheme({
|
||||
colors: {
|
||||
brand: '#0055FF',
|
||||
primary: '#3b82f6',
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
},
|
||||
spacing: {
|
||||
1: '8px',
|
||||
2: '16px',
|
||||
4: '32px',
|
||||
},
|
||||
fontSizes: {
|
||||
small: '12px',
|
||||
medium: '16px',
|
||||
large: '20px',
|
||||
},
|
||||
radii: {
|
||||
sm: '4px',
|
||||
md: '8px',
|
||||
lg: '12px',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ThemeContractAnalyzer } from '../theme-contract-analyzer.js';
|
||||
|
||||
describe('ThemeContractAnalyzer', () => {
|
||||
let analyzer: ThemeContractAnalyzer;
|
||||
|
||||
beforeEach(() => {
|
||||
analyzer = new ThemeContractAnalyzer();
|
||||
});
|
||||
|
||||
describe('loadThemeContract', () => {
|
||||
it('should handle non-existent file paths gracefully', () => {
|
||||
// Should not throw when file doesn't exist
|
||||
expect(() => {
|
||||
analyzer.loadThemeContract('./non-existent-theme.css.ts', __dirname);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(analyzer.hasContracts()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid file content gracefully', () => {
|
||||
// Create a temporary invalid file
|
||||
const invalidPath = path.join(__dirname, 'invalid-theme-temp.css.ts');
|
||||
fs.writeFileSync(invalidPath, 'this is not valid TypeScript {{{', 'utf-8');
|
||||
|
||||
try {
|
||||
// Should not throw even with invalid content
|
||||
expect(() => {
|
||||
analyzer.loadThemeContract(invalidPath, __dirname);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(analyzer.hasContracts()).toBe(false);
|
||||
} finally {
|
||||
// Clean up
|
||||
if (fs.existsSync(invalidPath)) {
|
||||
fs.unlinkSync(invalidPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle theme files without createTheme', () => {
|
||||
const noThemePath = path.join(__dirname, 'no-theme-temp.css.ts');
|
||||
fs.writeFileSync(noThemePath, 'export const someVariable = 123;', 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(noThemePath, __dirname);
|
||||
expect(analyzer.hasContracts()).toBe(false);
|
||||
} finally {
|
||||
if (fs.existsSync(noThemePath)) {
|
||||
fs.unlinkSync(noThemePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse createGlobalTheme', () => {
|
||||
const globalThemePath = path.join(__dirname, 'global-theme-temp.css.ts');
|
||||
const content = `
|
||||
import { createGlobalTheme } from '@vanilla-extract/css';
|
||||
export const vars = createGlobalTheme(':root', {
|
||||
color: {
|
||||
brand: '#0055FF'
|
||||
}
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(globalThemePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(globalThemePath, __dirname);
|
||||
expect(analyzer.hasContracts()).toBe(true);
|
||||
|
||||
const matches = analyzer.findMatchingTokens('#0055ff', 'color');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
if (fs.existsSync(globalThemePath)) {
|
||||
fs.unlinkSync(globalThemePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse createThemeContract', () => {
|
||||
const contractPath = path.join(__dirname, 'contract-temp.css.ts');
|
||||
const content = `
|
||||
import { createThemeContract } from '@vanilla-extract/css';
|
||||
export const themeContract = createThemeContract({
|
||||
spacing: {
|
||||
small: null,
|
||||
medium: null
|
||||
}
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(contractPath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(contractPath, __dirname);
|
||||
// createThemeContract still parses the structure
|
||||
expect(analyzer.hasContracts()).toBe(true);
|
||||
// But there should be no matchable values (null values aren't stored)
|
||||
expect(analyzer.findMatchingTokens('8px')).toHaveLength(0);
|
||||
} finally {
|
||||
if (fs.existsSync(contractPath)) {
|
||||
fs.unlinkSync(contractPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty theme objects', () => {
|
||||
const emptyPath = path.join(__dirname, 'empty-theme-temp.css.ts');
|
||||
const content = `
|
||||
import { createTheme } from '@vanilla-extract/css';
|
||||
export const theme = createTheme({});
|
||||
`;
|
||||
fs.writeFileSync(emptyPath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(emptyPath, __dirname);
|
||||
expect(analyzer.hasContracts()).toBe(true);
|
||||
expect(analyzer.findMatchingTokens('#fff', 'color')).toHaveLength(0);
|
||||
} finally {
|
||||
if (fs.existsSync(emptyPath)) {
|
||||
fs.unlinkSync(emptyPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle deeply nested theme objects', () => {
|
||||
const nestedPath = path.join(__dirname, 'nested-theme-temp.css.ts');
|
||||
const content = `
|
||||
import { createTheme } from '@vanilla-extract/css';
|
||||
export const theme = createTheme({
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
color: '#123456'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(nestedPath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(nestedPath, __dirname);
|
||||
const matches = analyzer.findMatchingTokens('#123456', 'color');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
// Path parsing may skip first level - just check it has nested structure
|
||||
expect(matches[0]?.tokenPath).toContain('level3.color');
|
||||
} finally {
|
||||
if (fs.existsSync(nestedPath)) {
|
||||
fs.unlinkSync(nestedPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMatchingTokens', () => {
|
||||
it('should return empty array for non-existent values', () => {
|
||||
const matches = analyzer.findMatchingTokens('#nonexistent');
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('should normalize and match 3-digit hex colors', () => {
|
||||
const themePath = path.join(__dirname, 'hex3-theme-temp.css.ts');
|
||||
const content = `
|
||||
export const theme = createTheme({ color: { brand: '#abc' } });
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
const matches = analyzer.findMatchingTokens('#aabbcc');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should normalize and match RGB colors', () => {
|
||||
const themePath = path.join(__dirname, 'rgb-theme-temp.css.ts');
|
||||
const content = `
|
||||
export const theme = createTheme({ color: { brand: 'rgb(255, 0, 0)' } });
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
const matches = analyzer.findMatchingTokens('rgb(255, 0, 0)');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should normalize RGBA to RGB when alpha is 1', () => {
|
||||
const themePath = path.join(__dirname, 'rgba-theme-temp.css.ts');
|
||||
const content = `
|
||||
export const theme = createTheme({ color: { brand: 'rgba(255, 0, 0, 1)' } });
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
const matches = analyzer.findMatchingTokens('rgb(255, 0, 0)');
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter by category when specified', () => {
|
||||
const themePath = path.join(__dirname, 'category-theme-temp.css.ts');
|
||||
const content = `
|
||||
export const theme = createTheme({
|
||||
colors: { brand: '#0055ff' },
|
||||
spacing: { small: '8px' }
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
|
||||
const colorMatches = analyzer.findMatchingTokens('#0055ff', 'color');
|
||||
expect(colorMatches.length).toBeGreaterThan(0);
|
||||
expect(colorMatches[0]?.category).toBe('color');
|
||||
|
||||
const spacingMatches = analyzer.findMatchingTokens('#0055ff', 'spacing');
|
||||
expect(spacingMatches).toHaveLength(0);
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('categorizeToken', () => {
|
||||
it('should categorize by value patterns when path is ambiguous', () => {
|
||||
const themePath = path.join(__dirname, 'pattern-theme-temp.css.ts');
|
||||
const content = `
|
||||
export const theme = createTheme({
|
||||
misc: {
|
||||
value1: '#ff0000',
|
||||
value2: '16px',
|
||||
value3: '100',
|
||||
value4: '0.5'
|
||||
}
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
|
||||
const colorMatch = analyzer.findMatchingTokens('#ff0000');
|
||||
expect(colorMatch[0]?.category).toBe('color');
|
||||
|
||||
const spacingMatch = analyzer.findMatchingTokens('16px');
|
||||
expect(spacingMatch[0]?.category).toBe('spacing');
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRemBase', () => {
|
||||
it('should update rem base for evaluation', () => {
|
||||
analyzer.setRemBase(20);
|
||||
|
||||
const themePath = path.join(__dirname, 'rembase-theme-temp.css.ts');
|
||||
const content = `
|
||||
const rem = (px) => \`\${px / 20}rem\`;
|
||||
export const theme = createTheme({
|
||||
spacing: { medium: rem(20) }
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
// The analyzer needs actual rem() evaluation in the source
|
||||
// For this test, we just verify setRemBase doesn't throw
|
||||
expect(analyzer.hasContracts()).toBe(true);
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariableName', () => {
|
||||
it('should return default when no contracts loaded', () => {
|
||||
expect(analyzer.getVariableName()).toBe('theme');
|
||||
});
|
||||
|
||||
it('should extract variable name from destructured export', () => {
|
||||
const themePath = path.join(__dirname, 'destructure-theme-temp.css.ts');
|
||||
const content = `
|
||||
export const [themeClass, vars] = createTheme({ color: { brand: '#000' } });
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
expect(analyzer.getVariableName()).toBe('vars');
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract variable name from single export', () => {
|
||||
const themePath = path.join(__dirname, 'single-theme-temp.css.ts');
|
||||
const content = `
|
||||
export const myTheme = createTheme({ color: { brand: '#000' } });
|
||||
`;
|
||||
fs.writeFileSync(themePath, content, 'utf-8');
|
||||
|
||||
try {
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
expect(analyzer.getVariableName()).toBe('myTheme');
|
||||
} finally {
|
||||
if (fs.existsSync(themePath)) {
|
||||
fs.unlinkSync(themePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all loaded contracts', () => {
|
||||
const themePath = path.join(__dirname, './test-theme.css.ts');
|
||||
|
||||
analyzer.loadThemeContract(themePath, __dirname);
|
||||
expect(analyzer.hasContracts()).toBe(true);
|
||||
|
||||
analyzer.clear();
|
||||
expect(analyzer.hasContracts()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { ValueEvaluator } from '../value-evaluator.js';
|
||||
|
||||
describe('ValueEvaluator', () => {
|
||||
describe('evaluate', () => {
|
||||
it('should evaluate string literals', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate("'hello'")).toBe('hello');
|
||||
expect(evaluator.evaluate('"world"')).toBe('world');
|
||||
expect(evaluator.evaluate('`test`')).toBe('test');
|
||||
});
|
||||
|
||||
it('should evaluate numeric literals', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('42')).toBe('42');
|
||||
expect(evaluator.evaluate('3.14')).toBe('3.14');
|
||||
});
|
||||
|
||||
it('should evaluate rem() calls', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('rem(16)')).toBe('1rem');
|
||||
expect(evaluator.evaluate('rem(32)')).toBe('2rem');
|
||||
expect(evaluator.evaluate('rem(8)')).toBe('0.5rem');
|
||||
});
|
||||
|
||||
it('should evaluate rem() with custom base', () => {
|
||||
const evaluator = new ValueEvaluator(20);
|
||||
expect(evaluator.evaluate('rem(20)')).toBe('1rem');
|
||||
expect(evaluator.evaluate('rem(40)')).toBe('2rem');
|
||||
});
|
||||
|
||||
it('should evaluate clsx() calls', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('clsx(rem(12), rem(16))')).toBe('0.75rem 1rem');
|
||||
expect(evaluator.evaluate("clsx('a', 'b', 'c')")).toBe('a b c');
|
||||
});
|
||||
|
||||
it('should evaluate template literals', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('`${rem(4)} ${rem(8)}`')).toBe('0.25rem 0.5rem');
|
||||
expect(evaluator.evaluate('`hello ${16} world`')).toBe('hello 16 world');
|
||||
});
|
||||
|
||||
it('should evaluate binary expressions - addition', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('16 + 32')).toBe('48');
|
||||
expect(evaluator.evaluate('"hello" + "world"')).toBe('helloworld');
|
||||
});
|
||||
|
||||
it('should evaluate binary expressions - subtraction', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('32 - 16')).toBe('16');
|
||||
expect(evaluator.evaluate('100 - 25')).toBe('75');
|
||||
});
|
||||
|
||||
it('should evaluate binary expressions - multiplication', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('16 * 2')).toBe('32');
|
||||
expect(evaluator.evaluate('5 * 3')).toBe('15');
|
||||
});
|
||||
|
||||
it('should evaluate binary expressions - division', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('32 / 2')).toBe('16');
|
||||
expect(evaluator.evaluate('100 / 4')).toBe('25');
|
||||
});
|
||||
|
||||
it('should return null for division by zero', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('100 / 0')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for invalid expressions', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('invalid syntax {{')).toBe(null);
|
||||
expect(evaluator.evaluate('someVariable')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for identifiers', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('myVariable')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for member expressions', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('obj.property')).toBe(null);
|
||||
expect(evaluator.evaluate('theme.colors.brand')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for unknown function calls', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('unknownFunc(42)')).toBe(null);
|
||||
expect(evaluator.evaluate('Math.floor(3.14)')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for rem() with no arguments', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('rem()')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for rem() with non-numeric argument', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('rem("notanumber")')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for rem() with spread argument', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('rem(...args)')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for clsx() with spread argument', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('clsx(...values)')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for clsx() with non-evaluable argument', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('clsx(someVariable)')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for template literal with non-evaluable expression', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('`hello ${someVariable}`')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for non-numeric subtraction', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('"hello" - "world"')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for non-numeric multiplication', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('"hello" * "world"')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for non-numeric division', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('"hello" / "world"')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for unsupported binary operators', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('16 % 5')).toBe(null);
|
||||
expect(evaluator.evaluate('2 ** 3')).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle complex nested expressions', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('rem(16 * 2)')).toBe('2rem');
|
||||
expect(evaluator.evaluate('rem(32 / 2)')).toBe('1rem');
|
||||
});
|
||||
|
||||
it('should handle template literals with multiple expressions', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('`${rem(4)} solid ${rem(8)}`')).toBe('0.25rem solid 0.5rem');
|
||||
});
|
||||
|
||||
it('should update remBase with setRemBase', () => {
|
||||
const evaluator = new ValueEvaluator(16);
|
||||
expect(evaluator.evaluate('rem(16)')).toBe('1rem');
|
||||
|
||||
evaluator.setRemBase(20);
|
||||
expect(evaluator.evaluate('rem(20)')).toBe('1rem');
|
||||
});
|
||||
|
||||
it('should handle empty template literals', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('``')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle template literals with only strings', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('`hello world`')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should handle negative numbers in rem()', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
// Negative numbers are UnaryExpression, which we don't handle directly
|
||||
// But they work inside rem() via parseFloat
|
||||
expect(evaluator.evaluate('rem(-16)')).toBe(null); // UnaryExpression not supported directly
|
||||
});
|
||||
|
||||
it('should handle decimal numbers', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('3.14')).toBe('3.14');
|
||||
expect(evaluator.evaluate('rem(24.5)')).toBe('1.53125rem');
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
const evaluator = new ValueEvaluator();
|
||||
expect(evaluator.evaluate('0')).toBe('0');
|
||||
expect(evaluator.evaluate('rem(0)')).toBe('0rem');
|
||||
});
|
||||
});
|
||||
});
|
||||
3
src/css-rules/prefer-theme-tokens/index.ts
Normal file
3
src/css-rules/prefer-theme-tokens/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import preferThemeTokensRule from './rule-definition.js';
|
||||
|
||||
export default preferThemeTokensRule;
|
||||
126
src/css-rules/prefer-theme-tokens/rule-definition.ts
Normal file
126
src/css-rules/prefer-theme-tokens/rule-definition.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { createThemeTokenVisitors } from './theme-token-visitor-creator.js';
|
||||
import type { ThemeTokenOptions } from './theme-token-processor.js';
|
||||
|
||||
const rule: Rule.RuleModule = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'require theme tokens instead of hard-coded values for colors, spacing, font sizes, and border radius',
|
||||
recommended: false,
|
||||
},
|
||||
fixable: 'code',
|
||||
// Suggestions are reported from helper modules, so static analysis in this file can’t detect them; disable the false positive.
|
||||
// eslint-disable-next-line eslint-plugin/require-meta-has-suggestions
|
||||
hasSuggestions: true,
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
themeContracts: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Array of theme contract file paths to analyze for intelligent token suggestions',
|
||||
},
|
||||
checkColors: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded color values',
|
||||
default: true,
|
||||
},
|
||||
checkSpacing: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded spacing values',
|
||||
default: true,
|
||||
},
|
||||
checkFontSizes: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded font size values',
|
||||
default: true,
|
||||
},
|
||||
checkBorderRadius: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded border radius values',
|
||||
default: true,
|
||||
},
|
||||
checkBorderWidths: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded border width values',
|
||||
default: true,
|
||||
},
|
||||
checkShadows: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded shadow values',
|
||||
default: true,
|
||||
},
|
||||
checkZIndex: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded z-index values',
|
||||
default: true,
|
||||
},
|
||||
checkOpacity: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded opacity values',
|
||||
default: true,
|
||||
},
|
||||
checkFontWeights: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded font weight values',
|
||||
default: true,
|
||||
},
|
||||
checkTransitions: {
|
||||
type: 'boolean',
|
||||
description: 'Check for hard-coded transition and animation values',
|
||||
default: true,
|
||||
},
|
||||
allowedValues: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Array of values that are allowed (e.g., "0", "auto", "100%")',
|
||||
},
|
||||
allowedProperties: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
description: 'Array of CSS properties to skip checking (supports both camelCase and kebab-case)',
|
||||
},
|
||||
autoFix: {
|
||||
type: 'boolean',
|
||||
description: 'Enable auto-fix for unambiguous token replacements',
|
||||
default: false,
|
||||
},
|
||||
remBase: {
|
||||
type: 'number',
|
||||
description: 'Base font size for rem() calculations (default: 16)',
|
||||
default: 16,
|
||||
},
|
||||
checkHelperFunctions: {
|
||||
type: 'boolean',
|
||||
description: 'Check helper function calls like rem(48) and suggest theme tokens (default: false)',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
hardCodedValueWithToken: "Hard-coded {{property}} value '{{value}}' detected. Use theme token: {{tokenPath}}",
|
||||
hardCodedValueGeneric:
|
||||
"Hard-coded {{property}} value '{{value}}' detected. Consider using a theme token from {{categoryHint}}",
|
||||
hardCodedValueNoContract:
|
||||
"Hard-coded {{property}} value '{{value}}' detected. Consider using theme tokens for {{category}} values",
|
||||
replaceWithToken: 'Replace with {{tokenPath}}',
|
||||
},
|
||||
},
|
||||
create(context: Rule.RuleContext): Rule.RuleListener {
|
||||
const options: ThemeTokenOptions = context.options[0] || {};
|
||||
return createThemeTokenVisitors(context, options);
|
||||
},
|
||||
};
|
||||
|
||||
export default rule;
|
||||
383
src/css-rules/prefer-theme-tokens/theme-contract-analyzer.ts
Normal file
383
src/css-rules/prefer-theme-tokens/theme-contract-analyzer.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ValueEvaluator } from './value-evaluator.js';
|
||||
|
||||
export interface TokenInfo {
|
||||
value: string;
|
||||
tokenPath: string;
|
||||
category: 'color' | 'spacing' | 'fontSize' | 'borderRadius' | 'borderWidth' | 'shadow' | 'zIndex' | 'opacity' | 'fontWeight' | 'transition' | 'other';
|
||||
}
|
||||
|
||||
export interface ThemeContract {
|
||||
tokens: Map<string, TokenInfo[]>; // value -> token infos
|
||||
variableName: string; // e.g., 'theme', 'vars'
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes theme contract files to extract token values and their paths
|
||||
*/
|
||||
export class ThemeContractAnalyzer {
|
||||
private contracts: Map<string, ThemeContract> = new Map();
|
||||
private evaluator: ValueEvaluator = new ValueEvaluator();
|
||||
|
||||
/**
|
||||
* Set rem base for evaluation (default: 16)
|
||||
*/
|
||||
setRemBase(base: number): void {
|
||||
this.evaluator.setRemBase(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and analyze a theme contract file
|
||||
*/
|
||||
loadThemeContract(filePath: string, baseDir: string): void {
|
||||
const absolutePath = path.resolve(baseDir, filePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(absolutePath, 'utf-8');
|
||||
const contract = this.parseThemeContract(content);
|
||||
if (contract) {
|
||||
this.contracts.set(filePath, contract);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - theme file might not be available during linting
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse theme contract from source code
|
||||
*/
|
||||
private parseThemeContract(content: string): ThemeContract | null {
|
||||
const tokens = new Map<string, TokenInfo[]>();
|
||||
let variableName = 'theme';
|
||||
|
||||
// Try to extract theme variable name from exports
|
||||
// Prefer the second identifier in destructuring like: export const [themeClass, vars] = createTheme(...)
|
||||
const destructureMatch = content.match(/export\s+(?:const|let)\s*\[\s*(\w+)\s*,\s*(\w+)\s*\]\s*=/);
|
||||
if (destructureMatch?.[2]) {
|
||||
variableName = destructureMatch[2];
|
||||
} else {
|
||||
// Fallback: capture single identifier export
|
||||
const exportMatch = content.match(/export\s+(?:const|let)\s+(\w+)\s*=|export\s+(?:const|let)\s*\[\s*(\w+)\s*\]/);
|
||||
const candidate = exportMatch?.[1] || exportMatch?.[2];
|
||||
if (candidate) {
|
||||
variableName = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse createTheme (one-arg), createTheme(contract, values), createGlobalTheme, or contract definitions
|
||||
const createThemeTwoArg = content.match(/createTheme\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*({[\s\S]*?})\s*\)/);
|
||||
const themeObjectMatch = content.match(/createTheme\s*\(\s*({[\s\S]*?})\s*\)/);
|
||||
const globalThemeMatch = content.match(/createGlobalTheme\s*\([^,]+,\s*({[\s\S]*?})\s*\)/);
|
||||
const contractMatch = content.match(/createThemeContract\s*\(\s*({[\s\S]*?})\s*\)/);
|
||||
|
||||
const themeContent = createThemeTwoArg?.[2] || themeObjectMatch?.[1] || globalThemeMatch?.[1] || contractMatch?.[1];
|
||||
|
||||
if (!themeContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If using createTheme(contractIdentifier, values), prefer the contract identifier for variable name (e.g., 'theme')
|
||||
if (createThemeTwoArg?.[1]) {
|
||||
variableName = createThemeTwoArg[1];
|
||||
} else {
|
||||
// Try to extract theme variable name from exports
|
||||
// Prefer the second identifier in destructuring like: export const [themeClass, vars] = createTheme(...)
|
||||
const destructureMatch = content.match(/export\s+(?:const|let)\s*\[\s*(\w+)\s*,\s*(\w+)\s*\]\s*=/);
|
||||
if (destructureMatch?.[2]) {
|
||||
variableName = destructureMatch[2];
|
||||
} else {
|
||||
// Fallback: capture single identifier export
|
||||
const exportMatch = content.match(/export\s+(?:const|let)\s+(\w+)\s*=|export\s+(?:const|let)\s*\[\s*(\w+)\s*\]/);
|
||||
const candidate = exportMatch?.[1] || exportMatch?.[2];
|
||||
if (candidate) {
|
||||
variableName = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the theme object structure
|
||||
this.parseThemeObject(themeContent, variableName, tokens);
|
||||
|
||||
return { tokens, variableName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse theme object and extract token values
|
||||
*/
|
||||
private parseThemeObject(
|
||||
content: string,
|
||||
variableName: string,
|
||||
tokens: Map<string, TokenInfo[]>,
|
||||
pathPrefix: string = '',
|
||||
): void {
|
||||
// Remove comments
|
||||
const cleaned = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// First, extract and process nested objects to avoid matching their contents
|
||||
const objectRegex = /['"]?(\w+)['"]?\s*:\s*{([^{}]*(?:{[^{}]*}[^{}]*)*?)}/g;
|
||||
const nestedObjects: Array<{ key: string; content: string }> = [];
|
||||
let objectMatch;
|
||||
|
||||
while ((objectMatch = objectRegex.exec(cleaned)) !== null) {
|
||||
const key = objectMatch[1];
|
||||
const nestedContent = objectMatch[2];
|
||||
if (key && nestedContent) {
|
||||
nestedObjects.push({ key, content: nestedContent });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove nested objects from content to avoid double-matching
|
||||
let contentWithoutNested = cleaned;
|
||||
nestedObjects.forEach(({ content }) => {
|
||||
contentWithoutNested = contentWithoutNested.replace(content, '');
|
||||
});
|
||||
|
||||
// Match key-value pairs - both string literals and expressions
|
||||
// String literals: key: 'value' or key: "value" or key: `value`
|
||||
const stringLiteralRegex = /['"]?(\w+)['"]?\s*:\s*(['"`])(.*?)\2/g;
|
||||
// Expressions: key: rem(16) or key: clsx(...)
|
||||
const expressionRegex = /['"]?(\w+)['"]?\s*:\s*([^,}\n]+?)(?=[,}\n])/g;
|
||||
|
||||
let match;
|
||||
|
||||
// First pass: extract string literals (only from content without nested objects)
|
||||
while ((match = stringLiteralRegex.exec(contentWithoutNested)) !== null) {
|
||||
const key = match[1];
|
||||
const value = match[3];
|
||||
if (!key || !value) continue;
|
||||
|
||||
const tokenPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
const fullPath = `${variableName}.${tokenPath}`;
|
||||
|
||||
// Determine category based on key name and value
|
||||
const category = this.categorizeToken(tokenPath, value);
|
||||
|
||||
// Normalize the value
|
||||
const normalizedValue = this.normalizeValue(value);
|
||||
|
||||
if (normalizedValue) {
|
||||
const existing = tokens.get(normalizedValue) || [];
|
||||
existing.push({ value: normalizedValue, tokenPath: fullPath, category });
|
||||
tokens.set(normalizedValue, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: extract and evaluate expressions (only from content without nested objects)
|
||||
let exprMatch;
|
||||
while ((exprMatch = expressionRegex.exec(contentWithoutNested)) !== null) {
|
||||
const key = exprMatch[1];
|
||||
let value = exprMatch[2];
|
||||
if (!key || !value) continue;
|
||||
|
||||
value = value.trim();
|
||||
|
||||
// Skip if it's a string literal (already processed)
|
||||
if (value.startsWith('"') || value.startsWith("'") || value.startsWith('`')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip nested objects
|
||||
if (value.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
const fullPath = `${variableName}.${tokenPath}`;
|
||||
|
||||
// Try to evaluate the expression
|
||||
const evaluatedValue = this.evaluator.evaluate(value);
|
||||
if (!evaluatedValue) continue;
|
||||
|
||||
// Determine category based on key name and evaluated value
|
||||
const category = this.categorizeToken(tokenPath, evaluatedValue);
|
||||
|
||||
// Normalize the evaluated value
|
||||
const normalizedValue = this.normalizeValue(evaluatedValue);
|
||||
|
||||
if (normalizedValue) {
|
||||
const existing = tokens.get(normalizedValue) || [];
|
||||
existing.push({ value: normalizedValue, tokenPath: fullPath, category });
|
||||
tokens.set(normalizedValue, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested objects (already extracted earlier)
|
||||
nestedObjects.forEach(({ key, content }) => {
|
||||
const newPrefix = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
this.parseThemeObject(content, variableName, tokens, newPrefix);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize a token based on its path and value
|
||||
*/
|
||||
private categorizeToken(tokenPath: string, value: string): TokenInfo['category'] {
|
||||
const lowerPath = tokenPath.toLowerCase();
|
||||
|
||||
// Check by path
|
||||
if (lowerPath.includes('color') || lowerPath.includes('bg') || lowerPath.includes('background')) {
|
||||
return 'color';
|
||||
}
|
||||
if (lowerPath.includes('spacing') || lowerPath.includes('space') || lowerPath.includes('gap')) {
|
||||
return 'spacing';
|
||||
}
|
||||
if (lowerPath.includes('fontsize') || lowerPath.includes('font') && lowerPath.includes('size')) {
|
||||
return 'fontSize';
|
||||
}
|
||||
if (lowerPath.includes('radius') || lowerPath.includes('radii')) {
|
||||
return 'borderRadius';
|
||||
}
|
||||
if (lowerPath.includes('borderwidth') || lowerPath.includes('border') && lowerPath.includes('width')) {
|
||||
return 'borderWidth';
|
||||
}
|
||||
if (lowerPath.includes('shadow')) {
|
||||
return 'shadow';
|
||||
}
|
||||
if (lowerPath.includes('zindex') || lowerPath.includes('z-index') || lowerPath.includes('z')) {
|
||||
return 'zIndex';
|
||||
}
|
||||
if (lowerPath.includes('opacity')) {
|
||||
return 'opacity';
|
||||
}
|
||||
if (lowerPath.includes('fontweight') || lowerPath.includes('font') && lowerPath.includes('weight') || lowerPath.includes('weight')) {
|
||||
return 'fontWeight';
|
||||
}
|
||||
if (lowerPath.includes('transition') || lowerPath.includes('animation') || lowerPath.includes('duration') || lowerPath.includes('delay')) {
|
||||
return 'transition';
|
||||
}
|
||||
|
||||
// Check by value pattern
|
||||
if (this.isColor(value)) {
|
||||
return 'color';
|
||||
}
|
||||
if (/^\d+(\.\d+)?(px|rem|em)$/.test(value)) {
|
||||
if (/font|size/.test(lowerPath)) {
|
||||
return 'fontSize';
|
||||
}
|
||||
if (/radius/.test(lowerPath)) {
|
||||
return 'borderRadius';
|
||||
}
|
||||
if (/border.*width|width/.test(lowerPath)) {
|
||||
return 'borderWidth';
|
||||
}
|
||||
return 'spacing';
|
||||
}
|
||||
if (/^-?\d+$/.test(value)) {
|
||||
return 'zIndex';
|
||||
}
|
||||
if (/^(0?\.\d+|1(\.0+)?)$/.test(value)) {
|
||||
return 'opacity';
|
||||
}
|
||||
if (/^[1-9]00$/.test(value) || /^(normal|bold|bolder|lighter)$/i.test(value)) {
|
||||
return 'fontWeight';
|
||||
}
|
||||
if (/^\d+(\.\d+)?(s|ms)$/.test(value) || /(ease|linear|cubic-bezier|steps)/i.test(value)) {
|
||||
return 'transition';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a color
|
||||
*/
|
||||
private isColor(value: string): boolean {
|
||||
return /^#[0-9a-f]{3,8}$/i.test(value) ||
|
||||
/^rgba?\(/.test(value) ||
|
||||
/^hsla?\(/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a value for comparison
|
||||
*/
|
||||
private normalizeValue(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Normalize hex colors
|
||||
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
|
||||
// Expand 3-digit hex to 6-digit
|
||||
const r = trimmed[1];
|
||||
const g = trimmed[2];
|
||||
const b = trimmed[3];
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
|
||||
if (/^#[0-9a-f]{6}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
// Normalize RGB/RGBA
|
||||
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
|
||||
if (rgbMatch?.[1] && rgbMatch[2] && rgbMatch[3]) {
|
||||
const r = parseInt(rgbMatch[1]);
|
||||
const g = parseInt(rgbMatch[2]);
|
||||
const b = parseInt(rgbMatch[3]);
|
||||
const a = rgbMatch[4];
|
||||
|
||||
if (a && a !== '1') {
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
// Normalize spacing values
|
||||
const spacingMatch = trimmed.match(/^(\d+(\.\d+)?)(px|rem|em|%)$/);
|
||||
if (spacingMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Return as-is for other values
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching tokens for a given value and category
|
||||
*/
|
||||
findMatchingTokens(value: string, category?: TokenInfo['category']): TokenInfo[] {
|
||||
const normalized = this.normalizeValue(value);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allMatches: TokenInfo[] = [];
|
||||
|
||||
for (const contract of this.contracts.values()) {
|
||||
const matches = contract.tokens.get(normalized) || [];
|
||||
allMatches.push(...matches);
|
||||
}
|
||||
|
||||
// Filter by category if specified
|
||||
if (category) {
|
||||
return allMatches.filter((token) => token.category === category);
|
||||
}
|
||||
|
||||
return allMatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary variable name from loaded contracts
|
||||
*/
|
||||
getVariableName(): string {
|
||||
const firstContract = Array.from(this.contracts.values())[0];
|
||||
return firstContract?.variableName || 'theme';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any contracts are loaded
|
||||
*/
|
||||
hasContracts(): boolean {
|
||||
return this.contracts.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all loaded contracts
|
||||
*/
|
||||
clear(): void {
|
||||
this.contracts.clear();
|
||||
}
|
||||
}
|
||||
708
src/css-rules/prefer-theme-tokens/theme-token-processor.ts
Normal file
708
src/css-rules/prefer-theme-tokens/theme-token-processor.ts
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
import type { Rule } from 'eslint';
|
||||
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
|
||||
import { ValueEvaluator } from './value-evaluator.js';
|
||||
import type { ThemeContractAnalyzer, TokenInfo } from './theme-contract-analyzer.js';
|
||||
|
||||
export interface ThemeTokenOptions {
|
||||
themeContracts?: string[];
|
||||
checkColors?: boolean;
|
||||
checkSpacing?: boolean;
|
||||
checkFontSizes?: boolean;
|
||||
checkBorderRadius?: boolean;
|
||||
checkBorderWidths?: boolean;
|
||||
checkShadows?: boolean;
|
||||
checkZIndex?: boolean;
|
||||
checkOpacity?: boolean;
|
||||
checkFontWeights?: boolean;
|
||||
checkTransitions?: boolean;
|
||||
allowedValues?: string[];
|
||||
allowedProperties?: string[];
|
||||
autoFix?: boolean;
|
||||
remBase?: number;
|
||||
checkHelperFunctions?: boolean;
|
||||
}
|
||||
|
||||
// Color detection patterns
|
||||
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const RGB_COLOR = /^rgba?\s*\(/i;
|
||||
const HSL_COLOR = /^hsla?\s*\(/i;
|
||||
const NAMED_COLORS = new Set([
|
||||
'black',
|
||||
'white',
|
||||
'red',
|
||||
'green',
|
||||
'blue',
|
||||
'yellow',
|
||||
'orange',
|
||||
'purple',
|
||||
'pink',
|
||||
'gray',
|
||||
'grey',
|
||||
'brown',
|
||||
'cyan',
|
||||
'magenta',
|
||||
'lime',
|
||||
'navy',
|
||||
'teal',
|
||||
'olive',
|
||||
'maroon',
|
||||
'aqua',
|
||||
'fuchsia',
|
||||
'silver',
|
||||
'gold',
|
||||
'indigo',
|
||||
'violet',
|
||||
'tan',
|
||||
]);
|
||||
|
||||
// CSS keywords that should be allowed
|
||||
const ALLOWED_KEYWORDS = new Set(['transparent', 'currentcolor', 'inherit', 'initial', 'unset', 'revert']);
|
||||
|
||||
// Spacing-related properties
|
||||
const SPACING_PROPERTIES = new Set([
|
||||
'margin',
|
||||
'marginTop',
|
||||
'marginRight',
|
||||
'marginBottom',
|
||||
'marginLeft',
|
||||
'marginBlock',
|
||||
'marginBlockStart',
|
||||
'marginBlockEnd',
|
||||
'marginInline',
|
||||
'marginInlineStart',
|
||||
'marginInlineEnd',
|
||||
'padding',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'paddingBlock',
|
||||
'paddingBlockStart',
|
||||
'paddingBlockEnd',
|
||||
'paddingInline',
|
||||
'paddingInlineStart',
|
||||
'paddingInlineEnd',
|
||||
'gap',
|
||||
'rowGap',
|
||||
'columnGap',
|
||||
'gridGap',
|
||||
'gridRowGap',
|
||||
'gridColumnGap',
|
||||
'inset',
|
||||
'insetBlock',
|
||||
'insetBlockStart',
|
||||
'insetBlockEnd',
|
||||
'insetInline',
|
||||
'insetInlineStart',
|
||||
'insetInlineEnd',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'minHeight',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'blockSize',
|
||||
'inlineSize',
|
||||
'minBlockSize',
|
||||
'minInlineSize',
|
||||
'maxBlockSize',
|
||||
'maxInlineSize',
|
||||
]);
|
||||
|
||||
// Font size properties
|
||||
const FONT_SIZE_PROPERTIES = new Set(['fontSize', 'lineHeight']);
|
||||
|
||||
// Border radius properties
|
||||
const BORDER_RADIUS_PROPERTIES = new Set([
|
||||
'borderRadius',
|
||||
'borderTopLeftRadius',
|
||||
'borderTopRightRadius',
|
||||
'borderBottomLeftRadius',
|
||||
'borderBottomRightRadius',
|
||||
'borderStartStartRadius',
|
||||
'borderStartEndRadius',
|
||||
'borderEndStartRadius',
|
||||
'borderEndRadius',
|
||||
]);
|
||||
|
||||
// Border width properties
|
||||
const BORDER_WIDTH_PROPERTIES = new Set([
|
||||
'borderWidth',
|
||||
'borderTopWidth',
|
||||
'borderRightWidth',
|
||||
'borderBottomWidth',
|
||||
'borderLeftWidth',
|
||||
'borderBlockWidth',
|
||||
'borderBlockStartWidth',
|
||||
'borderBlockEndWidth',
|
||||
'borderInlineWidth',
|
||||
'borderInlineStartWidth',
|
||||
'borderInlineEndWidth',
|
||||
'outlineWidth',
|
||||
'columnRuleWidth',
|
||||
// Shorthands that include width
|
||||
'border',
|
||||
'borderTop',
|
||||
'borderRight',
|
||||
'borderBottom',
|
||||
'borderLeft',
|
||||
'borderBlock',
|
||||
'borderBlockStart',
|
||||
'borderBlockEnd',
|
||||
'borderInline',
|
||||
'borderInlineStart',
|
||||
'borderInlineEnd',
|
||||
'outline',
|
||||
]);
|
||||
|
||||
// Shadow properties
|
||||
const SHADOW_PROPERTIES = new Set(['boxShadow', 'textShadow', 'filter', 'backdropFilter']);
|
||||
|
||||
// Z-index property
|
||||
const Z_INDEX_PROPERTIES = new Set(['zIndex']);
|
||||
|
||||
// Opacity property
|
||||
const OPACITY_PROPERTIES = new Set(['opacity']);
|
||||
|
||||
// Font weight properties
|
||||
const FONT_WEIGHT_PROPERTIES = new Set(['fontWeight']);
|
||||
|
||||
// Transition and animation properties
|
||||
const TRANSITION_PROPERTIES = new Set([
|
||||
'transition',
|
||||
'transitionDelay',
|
||||
'transitionDuration',
|
||||
'transitionTimingFunction',
|
||||
'animation',
|
||||
'animationDelay',
|
||||
'animationDuration',
|
||||
'animationTimingFunction',
|
||||
]);
|
||||
|
||||
// Color properties
|
||||
const COLOR_PROPERTIES = new Set([
|
||||
'color',
|
||||
'backgroundColor',
|
||||
'borderColor',
|
||||
'borderTopColor',
|
||||
'borderRightColor',
|
||||
'borderBottomColor',
|
||||
'borderLeftColor',
|
||||
'borderBlockStartColor',
|
||||
'borderBlockEndColor',
|
||||
'borderInlineStartColor',
|
||||
'borderInlineEndColor',
|
||||
'outlineColor',
|
||||
'textDecorationColor',
|
||||
'caretColor',
|
||||
'columnRuleColor',
|
||||
'fill',
|
||||
'stroke',
|
||||
]);
|
||||
|
||||
const isHardCodedColor = (value: string): boolean => {
|
||||
if (ALLOWED_KEYWORDS.has(value.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
HEX_COLOR.test(value) || RGB_COLOR.test(value) || HSL_COLOR.test(value) || NAMED_COLORS.has(value.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const hasNumericValue = (value: string): boolean => {
|
||||
// Match numeric values with units (e.g., 10px, 1rem, 2em, 50%, etc.)
|
||||
return /\d+(\.\d+)?(px|rem|em|%|vh|vw|vmin|vmax|ch|ex)/.test(value);
|
||||
};
|
||||
|
||||
const hasShadowValue = (value: string): boolean => {
|
||||
// Match shadow values (e.g., "0 4px 6px rgba(...)", "inset 0 1px 2px #000")
|
||||
// Also matches filter functions like blur(), drop-shadow()
|
||||
return /(\d+px|\d+rem|rgba?\(|hsla?\(|#[0-9a-f]{3,8}|blur\(|drop-shadow\(|brightness\(|contrast\()/.test(
|
||||
value.toLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
const hasZIndexValue = (value: string): boolean => {
|
||||
// Match numeric z-index values
|
||||
return /^-?\d+$/.test(value.trim());
|
||||
};
|
||||
|
||||
const hasOpacityValue = (value: string): boolean => {
|
||||
// Match opacity values (0-1 or percentages)
|
||||
return /^(0?\.\d+|1(\.0+)?|\d+%)$/.test(value.trim());
|
||||
};
|
||||
|
||||
const hasFontWeightValue = (value: string): boolean => {
|
||||
// Match font weight values (numeric or named)
|
||||
const namedWeights = ['normal', 'bold', 'bolder', 'lighter'];
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return /^[1-9]00$/.test(trimmed) || namedWeights.includes(trimmed);
|
||||
};
|
||||
|
||||
const hasTransitionValue = (value: string): boolean => {
|
||||
// Match transition/animation values (e.g., "0.3s", "200ms", "ease-in-out", "cubic-bezier(...)")
|
||||
return /(^\d+(\.\d+)?(s|ms)$|ease|linear|cubic-bezier\(|steps\()/.test(value.toLowerCase());
|
||||
};
|
||||
|
||||
const isAllowedValue = (value: string, allowedValues: Set<string>): boolean => {
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
allowedValues.has(trimmed) ||
|
||||
allowedValues.has(trimmed.toLowerCase()) ||
|
||||
trimmed === '0' ||
|
||||
trimmed === 'auto' ||
|
||||
trimmed === 'none' ||
|
||||
trimmed === 'inherit' ||
|
||||
trimmed === 'initial' ||
|
||||
trimmed === 'unset' ||
|
||||
/^\d+(\.\d+)?%$/.test(trimmed)
|
||||
); // Allow percentages
|
||||
};
|
||||
|
||||
const normalizePropertyName = (name: string): string => {
|
||||
// Convert kebab-case to camelCase
|
||||
return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes a vanilla-extract style object and reports hard-coded values
|
||||
* that should use theme tokens instead.
|
||||
*/
|
||||
export const processThemeTokensInStyleObject = (
|
||||
context: Rule.RuleContext,
|
||||
node: TSESTree.ObjectExpression,
|
||||
options: ThemeTokenOptions,
|
||||
analyzer: ThemeContractAnalyzer,
|
||||
): void => {
|
||||
const {
|
||||
checkColors = true,
|
||||
checkSpacing = true,
|
||||
checkFontSizes = true,
|
||||
checkBorderRadius = true,
|
||||
checkBorderWidths = true,
|
||||
checkShadows = true,
|
||||
checkZIndex = true,
|
||||
checkOpacity = true,
|
||||
checkFontWeights = true,
|
||||
checkTransitions = true,
|
||||
allowedValues = [],
|
||||
allowedProperties = [],
|
||||
autoFix = false,
|
||||
checkHelperFunctions = false,
|
||||
} = options;
|
||||
|
||||
const evaluator = new ValueEvaluator();
|
||||
|
||||
const allowedValuesSet = new Set(allowedValues);
|
||||
const allowedPropertiesSet = new Set([...allowedProperties.map(normalizePropertyName), ...allowedProperties]);
|
||||
|
||||
for (const property of node.properties) {
|
||||
if (property.type !== AST_NODE_TYPES.Property) continue;
|
||||
|
||||
// Determine property name
|
||||
let propertyName: string | null = null;
|
||||
if (property.key.type === AST_NODE_TYPES.Identifier) {
|
||||
propertyName = property.key.name;
|
||||
} else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') {
|
||||
propertyName = property.key.value;
|
||||
}
|
||||
|
||||
// Recurse into nested containers (@media, selectors, etc.)
|
||||
if (propertyName && (propertyName === '@media' || propertyName === 'selectors' || propertyName.startsWith('@'))) {
|
||||
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
for (const nestedProp of property.value.properties) {
|
||||
if (
|
||||
nestedProp.type === AST_NODE_TYPES.Property &&
|
||||
nestedProp.value.type === AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
processThemeTokensInStyleObject(context, nestedProp.value, options, analyzer);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!propertyName) continue;
|
||||
|
||||
// Skip if property is in allowed list
|
||||
if (allowedPropertiesSet.has(propertyName) || allowedPropertiesSet.has(normalizePropertyName(propertyName))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if property value is a literal (string or number)
|
||||
if (
|
||||
property.value.type === AST_NODE_TYPES.Literal &&
|
||||
(typeof property.value.value === 'string' || typeof property.value.value === 'number')
|
||||
) {
|
||||
const value = String(property.value.value);
|
||||
|
||||
// Skip if value is in allowed list
|
||||
if (isAllowedValue(value, allowedValuesSet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded colors
|
||||
if (checkColors && COLOR_PROPERTIES.has(propertyName) && isHardCodedColor(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'color', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded spacing
|
||||
if (checkSpacing && SPACING_PROPERTIES.has(propertyName) && hasNumericValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'spacing', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded font sizes
|
||||
if (checkFontSizes && FONT_SIZE_PROPERTIES.has(propertyName) && hasNumericValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'fontSize', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded border radius
|
||||
if (checkBorderRadius && BORDER_RADIUS_PROPERTIES.has(propertyName) && hasNumericValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'borderRadius', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded border widths
|
||||
if (checkBorderWidths && BORDER_WIDTH_PROPERTIES.has(propertyName) && hasNumericValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'borderWidth', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded shadows
|
||||
if (checkShadows && SHADOW_PROPERTIES.has(propertyName) && hasShadowValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'shadow', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded z-index
|
||||
if (checkZIndex && Z_INDEX_PROPERTIES.has(propertyName) && hasZIndexValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'zIndex', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded opacity
|
||||
if (checkOpacity && OPACITY_PROPERTIES.has(propertyName) && hasOpacityValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'opacity', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded font weights
|
||||
if (checkFontWeights && FONT_WEIGHT_PROPERTIES.has(propertyName) && hasFontWeightValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'fontWeight', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded transitions
|
||||
if (checkTransitions && TRANSITION_PROPERTIES.has(propertyName) && hasTransitionValue(value)) {
|
||||
reportHardCodedValue(context, property.value, value, 'transition', propertyName, analyzer, autoFix);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if property value is a TemplateLiteral (e.g., `${rem(4)} ${rem(8)}`)
|
||||
if (checkHelperFunctions && property.value.type === AST_NODE_TYPES.TemplateLiteral) {
|
||||
// Get the source code of the template literal
|
||||
const sourceCode = context.sourceCode || context.getSourceCode();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const templateText = sourceCode.getText(property.value as any);
|
||||
|
||||
// Try to evaluate it
|
||||
const evaluatedValue = evaluator.evaluate(templateText);
|
||||
|
||||
if (evaluatedValue) {
|
||||
// Check for hard-coded colors
|
||||
if (checkColors && COLOR_PROPERTIES.has(propertyName) && isHardCodedColor(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'color', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded spacing
|
||||
if (checkSpacing && SPACING_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'spacing', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded font sizes
|
||||
if (checkFontSizes && FONT_SIZE_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'fontSize', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded border radius
|
||||
if (checkBorderRadius && BORDER_RADIUS_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(
|
||||
context,
|
||||
property.value,
|
||||
evaluatedValue,
|
||||
'borderRadius',
|
||||
propertyName,
|
||||
analyzer,
|
||||
autoFix,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded border widths
|
||||
if (checkBorderWidths && BORDER_WIDTH_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'borderWidth', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded shadows
|
||||
if (checkShadows && SHADOW_PROPERTIES.has(propertyName) && hasShadowValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'shadow', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded z-index
|
||||
if (checkZIndex && Z_INDEX_PROPERTIES.has(propertyName) && hasZIndexValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'zIndex', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded opacity
|
||||
if (checkOpacity && OPACITY_PROPERTIES.has(propertyName) && hasOpacityValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'opacity', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded font weights
|
||||
if (checkFontWeights && FONT_WEIGHT_PROPERTIES.has(propertyName) && hasFontWeightValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'fontWeight', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded transitions
|
||||
if (checkTransitions && TRANSITION_PROPERTIES.has(propertyName) && hasTransitionValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'transition', propertyName, analyzer, autoFix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if property value is a CallExpression (e.g., rem(48), clsx(...))
|
||||
if (checkHelperFunctions && property.value.type === AST_NODE_TYPES.CallExpression) {
|
||||
// Get the source code of the call expression
|
||||
const sourceCode = context.sourceCode || context.getSourceCode();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const callExpressionText = sourceCode.getText(property.value as any);
|
||||
|
||||
// Try to evaluate it
|
||||
const evaluatedValue = evaluator.evaluate(callExpressionText);
|
||||
|
||||
if (evaluatedValue) {
|
||||
// Check for hard-coded colors
|
||||
if (checkColors && COLOR_PROPERTIES.has(propertyName) && isHardCodedColor(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'color', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded spacing
|
||||
if (checkSpacing && SPACING_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'spacing', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded font sizes
|
||||
if (checkFontSizes && FONT_SIZE_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'fontSize', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded border radius
|
||||
if (checkBorderRadius && BORDER_RADIUS_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(
|
||||
context,
|
||||
property.value,
|
||||
evaluatedValue,
|
||||
'borderRadius',
|
||||
propertyName,
|
||||
analyzer,
|
||||
autoFix,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded border widths
|
||||
if (checkBorderWidths && BORDER_WIDTH_PROPERTIES.has(propertyName) && hasNumericValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'borderWidth', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded shadows
|
||||
if (checkShadows && SHADOW_PROPERTIES.has(propertyName) && hasShadowValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'shadow', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded z-index
|
||||
if (checkZIndex && Z_INDEX_PROPERTIES.has(propertyName) && hasZIndexValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'zIndex', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded opacity
|
||||
if (checkOpacity && OPACITY_PROPERTIES.has(propertyName) && hasOpacityValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'opacity', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded font weights
|
||||
if (checkFontWeights && FONT_WEIGHT_PROPERTIES.has(propertyName) && hasFontWeightValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'fontWeight', propertyName, analyzer, autoFix);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for hard-coded transitions
|
||||
if (checkTransitions && TRANSITION_PROPERTIES.has(propertyName) && hasTransitionValue(evaluatedValue)) {
|
||||
reportHardCodedValue(context, property.value, evaluatedValue, 'transition', propertyName, analyzer, autoFix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested objects
|
||||
if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processThemeTokensInStyleObject(context, property.value, options, analyzer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Report a hard-coded value and suggest theme tokens
|
||||
*/
|
||||
|
||||
const reportHardCodedValue = (
|
||||
context: Rule.RuleContext,
|
||||
node: TSESTree.Literal | TSESTree.CallExpression | TSESTree.TemplateLiteral,
|
||||
value: string,
|
||||
category: TokenInfo['category'],
|
||||
propertyName: string,
|
||||
analyzer: ThemeContractAnalyzer,
|
||||
autoFix: boolean,
|
||||
): void => {
|
||||
// Find matching tokens from the theme contract
|
||||
const matchingTokens = analyzer.findMatchingTokens(value, category);
|
||||
|
||||
if (matchingTokens.length > 0) {
|
||||
// We have exact matches - suggest the specific token(s)
|
||||
const primaryToken = matchingTokens[0];
|
||||
if (!primaryToken) return;
|
||||
|
||||
const tokenPath = primaryToken.tokenPath;
|
||||
|
||||
const suggestions: Rule.SuggestionReportDescriptor[] = matchingTokens.map((token) => ({
|
||||
messageId: 'replaceWithToken',
|
||||
data: { tokenPath: token.tokenPath },
|
||||
fix: (fixer) => fixer.replaceText(node as unknown as Rule.Node, token.tokenPath),
|
||||
}));
|
||||
|
||||
const reportDescriptor: Rule.ReportDescriptor = {
|
||||
node: node as unknown as Rule.Node,
|
||||
messageId: 'hardCodedValueWithToken',
|
||||
data: {
|
||||
value,
|
||||
property: propertyName,
|
||||
tokenPath,
|
||||
},
|
||||
suggest: suggestions,
|
||||
};
|
||||
|
||||
// Add fix if autoFix is enabled
|
||||
// Only auto-fix when there's exactly one match (unambiguous)
|
||||
// For multiple matches, user must manually select from suggestions
|
||||
if (autoFix && matchingTokens.length === 1) {
|
||||
reportDescriptor.fix = (fixer) => fixer.replaceText(node as unknown as Rule.Node, tokenPath);
|
||||
}
|
||||
|
||||
context.report(reportDescriptor);
|
||||
} else if (analyzer.hasContracts()) {
|
||||
// Theme contract exists but no exact match - give generic suggestion
|
||||
const categoryHint = getCategoryHint(category, analyzer.getVariableName());
|
||||
|
||||
context.report({
|
||||
node: node as unknown as Rule.Node,
|
||||
messageId: 'hardCodedValueGeneric',
|
||||
data: {
|
||||
value,
|
||||
property: propertyName,
|
||||
categoryHint,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// No theme contract loaded - give very generic message
|
||||
context.report({
|
||||
node: node as unknown as Rule.Node,
|
||||
messageId: 'hardCodedValueNoContract',
|
||||
data: {
|
||||
value,
|
||||
property: propertyName,
|
||||
category: getCategoryName(category),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a helpful hint for the category
|
||||
*/
|
||||
const getCategoryHint = (category: TokenInfo['category'], variableName: string): string => {
|
||||
switch (category) {
|
||||
case 'color':
|
||||
return `${variableName}.colors.*`;
|
||||
case 'spacing':
|
||||
return `${variableName}.spacing.*`;
|
||||
case 'fontSize':
|
||||
return `${variableName}.fontSizes.*`;
|
||||
case 'borderRadius':
|
||||
return `${variableName}.radii.*`;
|
||||
case 'borderWidth':
|
||||
return `${variableName}.borderWidths.*`;
|
||||
case 'shadow':
|
||||
return `${variableName}.shadows.*`;
|
||||
case 'zIndex':
|
||||
return `${variableName}.zIndex.*`;
|
||||
case 'opacity':
|
||||
return `${variableName}.opacity.*`;
|
||||
case 'fontWeight':
|
||||
return `${variableName}.fontWeights.*`;
|
||||
case 'transition':
|
||||
return `${variableName}.transitions.*`;
|
||||
default:
|
||||
return `${variableName}.*`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a readable category name
|
||||
*/
|
||||
const getCategoryName = (category: TokenInfo['category']): string => {
|
||||
switch (category) {
|
||||
case 'color':
|
||||
return 'color';
|
||||
case 'spacing':
|
||||
return 'spacing';
|
||||
case 'fontSize':
|
||||
return 'font size';
|
||||
case 'borderRadius':
|
||||
return 'border radius';
|
||||
case 'borderWidth':
|
||||
return 'border width';
|
||||
case 'shadow':
|
||||
return 'shadow';
|
||||
case 'zIndex':
|
||||
return 'z-index';
|
||||
case 'opacity':
|
||||
return 'opacity';
|
||||
case 'fontWeight':
|
||||
return 'font weight';
|
||||
case 'transition':
|
||||
return 'transition';
|
||||
default:
|
||||
return 'value';
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import * as path from 'path';
|
||||
import type { Rule } from 'eslint';
|
||||
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
|
||||
import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js';
|
||||
import { ReferenceTracker, createReferenceTrackingVisitor } from '../shared-utils/reference-tracker.js';
|
||||
import { processStyleNode } from '../shared-utils/style-node-processor.js';
|
||||
import { ThemeContractAnalyzer } from './theme-contract-analyzer.js';
|
||||
import { processThemeTokensInStyleObject, type ThemeTokenOptions } from './theme-token-processor.js';
|
||||
|
||||
/**
|
||||
* Creates ESLint rule visitors for the prefer-theme-tokens rule
|
||||
*/
|
||||
export const createThemeTokenVisitors = (context: Rule.RuleContext, options: ThemeTokenOptions): Rule.RuleListener => {
|
||||
const tracker = new ReferenceTracker();
|
||||
const trackingVisitor = createReferenceTrackingVisitor(tracker);
|
||||
|
||||
// Initialize the theme contract analyzer
|
||||
const analyzer = new ThemeContractAnalyzer();
|
||||
|
||||
// Set rem base if provided
|
||||
if (options.remBase) {
|
||||
analyzer.setRemBase(options.remBase);
|
||||
}
|
||||
|
||||
// Load theme contracts if provided
|
||||
const themeContracts = options.themeContracts || [];
|
||||
if (themeContracts.length > 0) {
|
||||
// Use getCwd() to get project root, or fallback to linted file's directory
|
||||
const baseDir = context.getCwd ? context.getCwd() : path.dirname(context.filename || context.getFilename());
|
||||
|
||||
themeContracts.forEach((contractPath: string) => {
|
||||
analyzer.loadThemeContract(contractPath, baseDir);
|
||||
});
|
||||
}
|
||||
|
||||
const process = (context: Rule.RuleContext, object: TSESTree.ObjectExpression) =>
|
||||
processThemeTokensInStyleObject(context, object, options, analyzer);
|
||||
|
||||
return {
|
||||
...trackingVisitor,
|
||||
|
||||
// Call the tracking visitor's ImportDeclaration handler
|
||||
ImportDeclaration(node) {
|
||||
if (trackingVisitor.ImportDeclaration) {
|
||||
trackingVisitor.ImportDeclaration(node);
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== AST_NODE_TYPES.Identifier) return;
|
||||
|
||||
const functionName = node.callee.name;
|
||||
if (!tracker.isTrackedFunction(functionName)) return;
|
||||
|
||||
const originalName = tracker.getOriginalName(functionName);
|
||||
if (!originalName) return;
|
||||
|
||||
switch (originalName) {
|
||||
case 'fontFace':
|
||||
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
process(context, node.arguments[0] as TSESTree.ObjectExpression);
|
||||
}
|
||||
break;
|
||||
case 'globalFontFace':
|
||||
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
process(context, node.arguments[1] as TSESTree.ObjectExpression);
|
||||
}
|
||||
break;
|
||||
case 'style':
|
||||
case 'styleVariants':
|
||||
case 'keyframes':
|
||||
if (node.arguments.length > 0) {
|
||||
processStyleNode(context, node.arguments[0] as TSESTree.Node, (context, object) =>
|
||||
process(context, object),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'globalStyle':
|
||||
if (node.arguments.length > 1 && node.arguments[1]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
process(context, node.arguments[1] as TSESTree.ObjectExpression);
|
||||
}
|
||||
break;
|
||||
case 'recipe':
|
||||
case 'compoundVariant':
|
||||
if (node.arguments.length > 0 && node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
processRecipeProperties(context, node.arguments[0] as TSESTree.ObjectExpression, (context, object) =>
|
||||
process(context, object),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
227
src/css-rules/prefer-theme-tokens/value-evaluator.ts
Normal file
227
src/css-rules/prefer-theme-tokens/value-evaluator.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
|
||||
import { parseExpression } from '@babel/parser';
|
||||
import type {
|
||||
Expression,
|
||||
SpreadElement,
|
||||
PrivateName,
|
||||
TemplateLiteral,
|
||||
CallExpression,
|
||||
BinaryExpression,
|
||||
} from '@babel/types';
|
||||
|
||||
/**
|
||||
* Safe evaluator for static theme values with support for:
|
||||
* - rem() from polished
|
||||
* - clsx() for combining values
|
||||
* - Template literals with expressions
|
||||
* - Basic arithmetic
|
||||
*/
|
||||
export class ValueEvaluator {
|
||||
private remBase: number;
|
||||
|
||||
constructor(remBase: number = 16) {
|
||||
this.remBase = remBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a string expression to a concrete value
|
||||
*/
|
||||
evaluate(expression: string): string | null {
|
||||
try {
|
||||
const ast = parseExpression(expression, {
|
||||
sourceType: 'module',
|
||||
plugins: ['typescript'],
|
||||
});
|
||||
return this.evaluateNode(ast);
|
||||
} catch {
|
||||
// If parsing fails, return the original expression if it's a simple string
|
||||
const trimmed = expression.trim();
|
||||
if (
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith('`') && trimmed.endsWith('`'))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate an AST node
|
||||
*/
|
||||
private evaluateNode(node: Expression | SpreadElement | PrivateName | null): string | null {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'StringLiteral':
|
||||
return node.value;
|
||||
|
||||
case 'NumericLiteral':
|
||||
return String(node.value);
|
||||
|
||||
case 'TemplateLiteral':
|
||||
return this.evaluateTemplateLiteral(node);
|
||||
|
||||
case 'CallExpression':
|
||||
return this.evaluateCallExpression(node);
|
||||
|
||||
case 'BinaryExpression':
|
||||
return this.evaluateBinaryExpression(node);
|
||||
|
||||
case 'Identifier':
|
||||
// Only allow known safe identifiers
|
||||
return null;
|
||||
|
||||
case 'MemberExpression':
|
||||
// Don't evaluate member expressions for security
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a template literal
|
||||
*/
|
||||
private evaluateTemplateLiteral(node: TemplateLiteral): string | null {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (let i = 0; i < node.quasis.length; i++) {
|
||||
// Add the string part
|
||||
const quasi = node.quasis[i];
|
||||
if (!quasi) continue;
|
||||
parts.push(quasi.value.cooked || quasi.value.raw);
|
||||
|
||||
// Add the expression part if it exists
|
||||
if (i < node.expressions.length) {
|
||||
const exprValue = this.evaluateNode(node.expressions[i] as Expression);
|
||||
if (exprValue === null) {
|
||||
return null; // Can't evaluate this expression
|
||||
}
|
||||
parts.push(exprValue);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a call expression (rem, clsx, etc.)
|
||||
*/
|
||||
private evaluateCallExpression(node: CallExpression): string | null {
|
||||
// Get function name
|
||||
let functionName: string | null = null;
|
||||
if (node.callee.type === 'Identifier') {
|
||||
functionName = node.callee.name;
|
||||
} else if (node.callee.type === 'MemberExpression' &&
|
||||
node.callee.property.type === 'Identifier') {
|
||||
functionName = node.callee.property.name;
|
||||
}
|
||||
|
||||
if (!functionName) return null;
|
||||
|
||||
switch (functionName) {
|
||||
case 'rem':
|
||||
return this.evaluateRem(node);
|
||||
case 'clsx':
|
||||
return this.evaluateClsx(node);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate rem() function from polished
|
||||
*/
|
||||
private evaluateRem(node: CallExpression): string | null {
|
||||
if (node.arguments.length === 0) return null;
|
||||
|
||||
const arg = node.arguments[0];
|
||||
if (!arg || arg.type === 'SpreadElement') return null;
|
||||
|
||||
const value = this.evaluateNode(arg as Expression);
|
||||
if (value === null) return null;
|
||||
|
||||
const numValue = parseFloat(value);
|
||||
if (isNaN(numValue)) return null;
|
||||
|
||||
// Convert pixels to rem
|
||||
const remValue = numValue / this.remBase;
|
||||
return `${remValue}rem`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate clsx() function
|
||||
*/
|
||||
private evaluateClsx(node: CallExpression): string | null {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const arg of node.arguments) {
|
||||
if (arg.type === 'SpreadElement') return null;
|
||||
|
||||
const value = this.evaluateNode(arg as Expression);
|
||||
if (value === null) return null;
|
||||
|
||||
parts.push(value);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate binary expression (mainly for string concatenation)
|
||||
*/
|
||||
private evaluateBinaryExpression(node: BinaryExpression): string | null {
|
||||
const left = this.evaluateNode(node.left);
|
||||
const right = this.evaluateNode(node.right);
|
||||
|
||||
if (left === null || right === null) return null;
|
||||
|
||||
switch (node.operator) {
|
||||
case '+':
|
||||
// String concatenation or addition
|
||||
const leftNum = parseFloat(left);
|
||||
const rightNum = parseFloat(right);
|
||||
if (!isNaN(leftNum) && !isNaN(rightNum)) {
|
||||
return String(leftNum + rightNum);
|
||||
}
|
||||
return left + right;
|
||||
|
||||
case '-':
|
||||
const leftNum2 = parseFloat(left);
|
||||
const rightNum2 = parseFloat(right);
|
||||
if (!isNaN(leftNum2) && !isNaN(rightNum2)) {
|
||||
return String(leftNum2 - rightNum2);
|
||||
}
|
||||
return null;
|
||||
|
||||
case '*':
|
||||
const leftNum3 = parseFloat(left);
|
||||
const rightNum3 = parseFloat(right);
|
||||
if (!isNaN(leftNum3) && !isNaN(rightNum3)) {
|
||||
return String(leftNum3 * rightNum3);
|
||||
}
|
||||
return null;
|
||||
|
||||
case '/':
|
||||
const leftNum4 = parseFloat(left);
|
||||
const rightNum4 = parseFloat(right);
|
||||
if (!isNaN(leftNum4) && !isNaN(rightNum4) && rightNum4 !== 0) {
|
||||
return String(leftNum4 / rightNum4);
|
||||
}
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set rem base for evaluation
|
||||
*/
|
||||
setRemBase(base: number): void {
|
||||
this.remBase = base;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,12 @@ import noTrailingZeroRule from './css-rules/no-trailing-zero/rule-definition.js'
|
|||
import noUnknownUnitRule from './css-rules/no-unknown-unit/rule-definition.js';
|
||||
import noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js';
|
||||
import preferLogicalPropertiesRule from './css-rules/prefer-logical-properties/index.js';
|
||||
import preferThemeTokensRule from './css-rules/prefer-theme-tokens/index.js';
|
||||
|
||||
const vanillaExtract = {
|
||||
meta: {
|
||||
name: '@antebudimir/eslint-plugin-vanilla-extract',
|
||||
version: '1.14.0',
|
||||
version: '1.15.0',
|
||||
},
|
||||
rules: {
|
||||
'alphabetical-order': alphabeticalOrderRule,
|
||||
|
|
@ -23,6 +24,7 @@ const vanillaExtract = {
|
|||
'no-unknown-unit': noUnknownUnitRule,
|
||||
'no-zero-unit': noZeroUnitRule,
|
||||
'prefer-logical-properties': preferLogicalPropertiesRule,
|
||||
'prefer-theme-tokens': preferThemeTokensRule,
|
||||
},
|
||||
configs: {},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue