diff --git a/CHANGELOG.md b/CHANGELOG.md index d53d151..8e4aa36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.9.0] - 2025-04-16 + +- add new rule `no-unknown-unit` that disallows unknown or invalid CSS units in vanilla-extract style objects. + - Reports any usage of unrecognized units in property values + - Handles all vanilla-extract APIs, including style, recipe, fontFace, and keyframes + - Ignores valid units in special contexts (e.g., CSS functions, custom properties) + - Supports nested objects, media queries, and pseudo-selectors + - No autofix is provided because replacing or removing unknown units may result in unintended or invalid CSS; manual developer review is required + ## [1.8.0] - 2025-04-12 - add new rule `no-zero-unit` that enforces unitless zero values in vanilla-extract style objects diff --git a/README.md b/README.md index 3086400..7080921 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ The recommended configuration enables the following rules with error severity: - `vanilla-extract/concentric-order`: Enforces concentric CSS property ordering - `vanilla-extract/no-empty-style-blocks`: Prevents empty style blocks +- `vanilla-extract/no-unknown-unit`: prohibits usage of unrecognized CSS units. - `vanilla-extract/no-zero-unit`: removes unnecessary units for zero values You can use the recommended configuration as a starting point and override rules as needed for your project. @@ -110,6 +111,7 @@ export default [ sortRemainingProperties: 'concentric', // 'alphabetical' is default }, ], + 'vanilla-extract/no-unknown-unit': 'error', 'vanilla-extract/no-zero-unit': 'warn', }, }, @@ -269,6 +271,44 @@ export const recipeWithEmptyVariants = recipe({ }); ``` +## vanilla-extract/no-unknown-unit + +This rule enforces the use of valid CSS units in vanilla-extract style objects. It prevents typos and non-standard units that could cause styling issues or browser compatibility problems. + +```typescript +// ❌ Incorrect +import { style, globalStyle, recipe } from '@vanilla-extract/css'; + +export const invalidStyle = style({ + margin: '5abc', // Non-existent unit + fontSize: '1.5rems', // Typo in unit +}); + +export const myRecipe = recipe({ + variants: { + size: { + large: { padding: '4xm' } // Invalid unit + } + } +}); + +// ✅ Correct +import { style, globalStyle, recipe } from '@vanilla-extract/css'; + +export const validStyle = style({ + margin: '5rem', + fontSize: '1.5rem', +}); + +export const myRecipe = recipe({ + variants: { + size: { + large: { padding: '4em' } + } + } +}); +``` + ## vanilla-extract/no-zero-unit This rule enforces the removal of unnecessary units for zero values in vanilla-extract style objects. It helps maintain cleaner and more consistent CSS by eliminating redundant units when the value is zero. @@ -373,11 +413,12 @@ The roadmap outlines the project's current status and future plans: - `no-empty-style-blocks` rule to disallow empty blocks. - Recommended ESLint configuration for the plugin. - `no-zero-unit` rule to disallow units when the value is zero. +- `no-unknown-unit` rule to disallow unknown units. - Comprehensive rule testing. ### Current Work -- `no-unknown-unit` rule to disallow unknown units. +- Support for using the plugin’s recommended config via the extends field (as discussed in [issue #3](https://github.com/antebudimir/eslint-plugin-vanilla-extract/issues/3)) ### Upcoming Features @@ -386,6 +427,7 @@ The roadmap outlines the project's current status and future plans: - `prefer-logical-properties` rule to enforce use of logical properties. - `prefer-theme-tokens` rule to enforce use of theme tokens instead of hard-coded values when available. - `no-global-style` rule to disallow use of `globalStyle` function. +- `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 order. **Note**: This feature will only be implemented if there's sufficient interest from the community. ## Contributing diff --git a/package.json b/package.json index 14de43e..990fd06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antebudimir/eslint-plugin-vanilla-extract", - "version": "1.8.0", + "version": "1.9.0", "description": "ESLint plugin for enforcing best practices in vanilla-extract CSS styles, including CSS property ordering and additional linting rules.", "author": "Ante Budimir", "license": "MIT", diff --git a/src/css-rules/no-empty-blocks/__tests__/globals.test.ts b/src/css-rules/no-empty-blocks/__tests__/globals.test.ts index d3a7d01..bd57aa6 100644 --- a/src/css-rules/no-empty-blocks/__tests__/globals.test.ts +++ b/src/css-rules/no-empty-blocks/__tests__/globals.test.ts @@ -61,7 +61,6 @@ run({ }); `, - // Add these to the valid array // Test for global functions without enough arguments ` import { globalStyle } from '@vanilla-extract/css'; diff --git a/src/css-rules/no-unknown-unit/__tests__/no-unknown-unit.test.ts b/src/css-rules/no-unknown-unit/__tests__/no-unknown-unit.test.ts new file mode 100644 index 0000000..db114f2 --- /dev/null +++ b/src/css-rules/no-unknown-unit/__tests__/no-unknown-unit.test.ts @@ -0,0 +1,292 @@ +import tsParser from '@typescript-eslint/parser'; +import { run } from 'eslint-vitest-rule-tester'; +import noUnknownUnitRule from '../rule-definition.js'; + +run({ + name: 'vanilla-extract/no-unknown-unit', + rule: noUnknownUnitRule, + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + valid: [ + ` + import { style } from '@vanilla-extract/css'; + const valid = style({ + width: '100%', + padding: '2rem', + margin: '0', + fontSize: '1.5em', + }); + `, + + ` + import { style } from '@vanilla-extract/css'; + const nested = style({ + '@media': { + '(min-width: 768px)': { + padding: '2cqw', + margin: '1svh' + } + }, + selectors: { + '&:hover': { + rotate: '45deg' + } + } + }); + `, + + ` + import { recipe } from '@vanilla-extract/css'; + const button = recipe({ + variants: { + size: { + small: { padding: '4mm' }, + large: { fontSize: '2lh' } + } + } + }); + `, + + ` + import { fontFace } from '@vanilla-extract/css'; + const myFont = fontFace({ + src: 'local("Comic Sans")', + lineGap: '2.3ex' + }); + `, + + ` + import { style } from '@vanilla-extract/css'; + const noUnits = style({ + zIndex: 100, + opacity: 0.5, + flexGrow: 1 + }); + `, + + { + code: ` + import { style } from '@vanilla-extract/css'; + const caseTest = style({ + width: '10Px' // Should be valid (CSS is case-insensitive) + }); + `, + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + const viaMemberExpression = someObject.style({ + width: '10invalid' // Should be ignored + }); + `, + }, + { + code: ` + import { style } from '@vanilla-extract/css'; + const viaCallExpression = (style)(); + `, + }, + { + code: ` + import { style } from '@vanilla-extract/css'; + const nestedCall = someFn().style({ + padding: '5pct' // Should be ignored + }); + `, + }, + { + code: ` + import { style } from '@vanilla-extract/css'; + const taggedTemplate = style\`width: 10pxx\`; // Different AST structure + `, + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + style({ + width: \`10px\`, // Valid unit in template literal + height: \`calc(100% - \${10}px)\` // Should be ignored (multiple quasis) + }); + `, + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + style({ + margin: \` \${''} \`, // Empty template literal + padding: \`\${'2rem'}\` // Interpolation only + }); + `, + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + style({ + valid: '10px', + // Add nested non-object properties + invalidNested: [ { invalid: '10pxx' } ], // Array expression + invalidMedia: { + '@media': 'invalid-string' // String instead of object + } + }); + `, + }, + + { + code: ` + import { recipe } from '@vanilla-extract/css'; + recipe({ + base: { + valid: '1rem', + // Invalid nested structure + nestedInvalid: 'not-an-object' + } + }); + `, + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + const baseStyles = { padding: '1rem' }; + style({ + ...baseStyles, // Spread element (not a Property node) + margin: '2em' + }); + `, + }, + { + code: ` + import { style } from '@vanilla-extract/css'; + style({ + ...{ width: '10px' }, // Inline spread + height: '20vh' + }); + `, + }, + ], + + invalid: [ + // Basic invalid units + { + code: ` + import { style } from '@vanilla-extract/css'; + const invalid = style({ + width: '10pxx', + padding: '5pct' + });y + `, + errors: [ + { + messageId: 'unknownUnit', + data: { unit: 'pxx', value: '10pxx' }, + }, + { + messageId: 'unknownUnit', + data: { unit: 'pct', value: '5pct' }, + }, + ], + }, + + // Invalid units in nested contexts + { + code: ` + import { style } from '@vanilla-extract/css'; + const nestedInvalid = style({ + '@media': { + '(min-width: 768px)': { + margin: '10dvhx' + } + }, + selectors: { + '&:active': { + rotate: '90rads' + } + } + }); + `, + errors: [ + { messageId: 'unknownUnit', data: { unit: 'dvhx', value: '10dvhx' } }, + { messageId: 'unknownUnit', data: { unit: 'rads', value: '90rads' } }, + ], + }, + + // Invalid units in recipes + { + code: ` + import { recipe } from '@vanilla-extract/css'; + const invalidRecipe = recipe({ + base: { + fontSize: '12ptx' + }, + variants: { + spacing: { + large: { padding: '20inchs' } + } + } + }); + `, + errors: [ + { messageId: 'unknownUnit', data: { unit: 'ptx', value: '12ptx' } }, + { messageId: 'unknownUnit', data: { unit: 'inchs', value: '20inchs' } }, + ], + }, + + // Invalid units in global styles + { + code: ` + import { globalStyle } from '@vanilla-extract/css'; + globalStyle('body', { + margin: '5foot' + }); + `, + errors: [{ messageId: 'unknownUnit', data: { unit: 'foot', value: '5foot' } }], + }, + + // Complex value patterns + { + code: ` + import { style } from '@vanilla-extract/css'; + const complexValues = style({ + padding: '10px 20cmm', // Second value is invalid + margin: '1rem 2 3em 4whatever' + }); + `, + errors: [ + { messageId: 'unknownUnit', data: { unit: 'cmm', value: '20cmm' } }, + { messageId: 'unknownUnit', data: { unit: 'whatever', value: '4whatever' } }, + ], + }, + + { + code: ` + import { fontFace } from '@vanilla-extract/css'; + fontFace({ + src: 'local("Test Font")', + lineGap: '5foot' // Invalid unit + }); + `, + errors: [{ messageId: 'unknownUnit', data: { unit: 'foot', value: '5foot' } }], + }, + + { + code: ` + import { globalFontFace } from '@vanilla-extract/css'; + globalFontFace('MyFont', { + src: 'local("Test Font")', + ascentOverride: '10hand' // Invalid unit + }); + `, + errors: [{ messageId: 'unknownUnit', data: { unit: 'hand', value: '10hand' } }], + }, + ], +}); diff --git a/src/css-rules/no-unknown-unit/index.ts b/src/css-rules/no-unknown-unit/index.ts new file mode 100644 index 0000000..6d274be --- /dev/null +++ b/src/css-rules/no-unknown-unit/index.ts @@ -0,0 +1,3 @@ +import noUnknownUnitRule from './rule-definition.js'; + +export default noUnknownUnitRule; diff --git a/src/css-rules/no-unknown-unit/rule-definition.ts b/src/css-rules/no-unknown-unit/rule-definition.ts new file mode 100644 index 0000000..34a3455 --- /dev/null +++ b/src/css-rules/no-unknown-unit/rule-definition.ts @@ -0,0 +1,22 @@ +import type { Rule } from 'eslint'; +import { createUnknownUnitVisitors } from './unknown-unit-visitor-creator.js'; + +const noUnknownUnitRule: Rule.RuleModule = { + meta: { + type: 'problem', + docs: { + description: 'disallow invalid or unknown CSS units in vanilla-extract style objects', + category: 'Possible Errors', + recommended: true, + }, + schema: [], + messages: { + unknownUnit: 'The unit "{{ unit }}" in value "{{ value }}" is not recognized as a valid CSS unit.', + }, + }, + create(context) { + return createUnknownUnitVisitors(context); + }, +}; + +export default noUnknownUnitRule; diff --git a/src/css-rules/no-unknown-unit/unknown-unit-processor.ts b/src/css-rules/no-unknown-unit/unknown-unit-processor.ts new file mode 100644 index 0000000..8b05548 --- /dev/null +++ b/src/css-rules/no-unknown-unit/unknown-unit-processor.ts @@ -0,0 +1,196 @@ +import type { Rule } from 'eslint'; +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +/** + * List of valid CSS units according to CSS specifications. + */ +const VALID_CSS_UNITS = [ + // Absolute length units + 'px', + 'cm', + 'mm', + 'Q', + 'in', + 'pc', + 'pt', + // Relative length units + 'em', + 'ex', + 'ch', + 'rem', + 'lh', + 'rlh', + 'vw', + 'vh', + 'vmin', + 'vmax', + 'vb', + 'vi', + 'svw', + 'svh', + 'lvw', + 'lvh', + 'dvw', + 'dvh', + // Percentage + '%', + // Angle units + 'deg', + 'grad', + 'rad', + 'turn', + // Time units + 'ms', + 's', + // Frequency units + 'Hz', + 'kHz', + // Resolution units + 'dpi', + 'dpcm', + 'dppx', + 'x', + // Flexible length units + 'fr', + // Other valid units + 'cap', + 'ic', + 'rex', + 'cqw', + 'cqh', + 'cqi', + 'cqb', + 'cqmin', + 'cqmax', +]; + +/** + * Regular expression to extract units from CSS values. + * Matches numeric values followed by a unit. + */ +const CSS_VALUE_WITH_UNIT_REGEX = /^(-?\d*\.?\d+)([a-zA-Z%]+)$/i; + +/** + * Splits a CSS value string into individual parts, handling spaces not inside functions. + */ +const splitCssValues = (value: string): string[] => { + return value + .split(/(? part.trim()) + .filter((part) => part.length > 0); +}; + +/** + * Check if a CSS value contains a valid CSS unit. + */ +const checkCssUnit = ( + value: string, +): { hasUnit: boolean; unit: string | null; isValid: boolean; invalidValue?: string } => { + const values = splitCssValues(value); + + for (const value of values) { + // Skip values containing CSS functions + if (value.includes('(')) { + continue; + } + + const match = value.match(CSS_VALUE_WITH_UNIT_REGEX); + if (!match) { + continue; + } + + const unit = match[2]!.toLowerCase(); // match[2] is guaranteed by regex pattern + if (!VALID_CSS_UNITS.includes(unit)) { + return { + hasUnit: true, + unit: match[2]!, // Preserve original casing + isValid: false, + invalidValue: value, + }; + } + } + + return { hasUnit: false, unit: null, isValid: true }; +}; + +/** + * Extracts string value from a node if it's a string literal or template literal. + */ +const getStringValue = (node: TSESTree.Node): string | null => { + if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') { + return node.value; + } + + if (node.type === AST_NODE_TYPES.TemplateLiteral && node.quasis.length === 1) { + const firstQuasi = node.quasis[0]; + return firstQuasi?.value.raw ? firstQuasi.value.raw : null; + } + + return null; +}; + +/** + * Recursively processes a style object, reporting instances of + * unknown CSS units. + * + * @param context The ESLint rule context. + * @param node The ObjectExpression node representing the style object to be + * processed. + */ +export const processUnknownUnitInStyleObject = (context: Rule.RuleContext, node: TSESTree.ObjectExpression): void => { + // Defensive: This function is only called with ObjectExpression nodes by the rule visitor. + // This check's for type safety and future-proofing. It's not covered by rule tests + // because the rule architecture prevents non-ObjectExpression nodes from reaching here. + if (!node || node.type !== AST_NODE_TYPES.ObjectExpression) { + return; + } + + for (const property of node.properties) { + if (property.type !== AST_NODE_TYPES.Property) { + continue; + } + + // Get property key name if possible + 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; + } + + if (propertyName === '@media' || propertyName === 'selectors') { + if (property.value.type === AST_NODE_TYPES.ObjectExpression) { + for (const nestedProperty of property.value.properties) { + if ( + nestedProperty.type === AST_NODE_TYPES.Property && + nestedProperty.value.type === AST_NODE_TYPES.ObjectExpression + ) { + processUnknownUnitInStyleObject(context, nestedProperty.value); + } + } + } + continue; + } + + // Process direct string values + const value = getStringValue(property.value); + if (value) { + const result = checkCssUnit(value); + if (result.hasUnit && !result.isValid && result.invalidValue) { + context.report({ + node: property.value as Rule.Node, + messageId: 'unknownUnit', + data: { + unit: result.unit || '', + value: result.invalidValue, + }, + }); + } + } + + // Process nested objects (including those not handled by special cases) + if (property.value.type === AST_NODE_TYPES.ObjectExpression) { + processUnknownUnitInStyleObject(context, property.value); + } + } +}; diff --git a/src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts b/src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts new file mode 100644 index 0000000..975b9db --- /dev/null +++ b/src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts @@ -0,0 +1,52 @@ +import type { Rule } from 'eslint'; +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { processRecipeProperties } from '../shared-utils/recipe-property-processor.js'; +import { processStyleNode } from '../shared-utils/style-node-processor.js'; +import { processUnknownUnitInStyleObject } from './unknown-unit-processor.js'; + +/** + * Creates ESLint rule visitors for detecting and processing unknown CSS units + * in style-related function calls. + */ +export const createUnknownUnitVisitors = (context: Rule.RuleContext): Rule.RuleListener => { + return { + CallExpression(node) { + if (node.callee.type !== AST_NODE_TYPES.Identifier) { + return; + } + + if (['fontFace', 'globalFontFace'].includes(node.callee.name)) { + const argumentIndex = node.callee.name === 'fontFace' ? 0 : 1; + if ( + node.arguments.length > argumentIndex && + node.arguments[argumentIndex]?.type === AST_NODE_TYPES.ObjectExpression + ) { + processUnknownUnitInStyleObject(context, node.arguments[argumentIndex] as TSESTree.ObjectExpression); + } + return; + } + + if (['keyframes', 'style', 'styleVariants'].includes(node.callee.name)) { + if (node.arguments.length > 0) { + processStyleNode(context, node.arguments[0] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); + } + } + + if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) { + processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processUnknownUnitInStyleObject); + } + + if ( + node.callee.name === 'recipe' && + node.arguments.length > 0 && + node.arguments[0]?.type === AST_NODE_TYPES.ObjectExpression + ) { + processRecipeProperties( + context, + node.arguments[0] as TSESTree.ObjectExpression, + processUnknownUnitInStyleObject, + ); + } + }, + }; +}; diff --git a/src/css-rules/no-zero-unit/rule-definition.ts b/src/css-rules/no-zero-unit/rule-definition.ts index 698674a..b1dc12e 100644 --- a/src/css-rules/no-zero-unit/rule-definition.ts +++ b/src/css-rules/no-zero-unit/rule-definition.ts @@ -12,7 +12,7 @@ const noZeroUnitRule: Rule.RuleModule = { fixable: 'code', schema: [], messages: { - noZeroUnit: 'Unit with zero value is unnecessary. Use 0 instead.', + noZeroUnit: 'Zero values don’t need a unit. Replace with "0".', }, }, create(context) { diff --git a/src/index.ts b/src/index.ts index 942507e..28dd332 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,18 +2,20 @@ import alphabeticalOrderRule from './css-rules/alphabetical-order/index.js'; import concentricOrderRule from './css-rules/concentric-order/index.js'; import customOrderRule from './css-rules/custom-order/rule-definition.js'; import noEmptyStyleBlocksRule from './css-rules/no-empty-blocks/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'; export const vanillaExtract = { meta: { name: '@antebudimir/eslint-plugin-vanilla-extract', - version: '1.8.0', + version: '1.9.0', }, rules: { 'alphabetical-order': alphabeticalOrderRule, 'concentric-order': concentricOrderRule, 'custom-order': customOrderRule, 'no-empty-style-blocks': noEmptyStyleBlocksRule, + 'no-unknown-unit': noUnknownUnitRule, 'no-zero-unit': noZeroUnitRule, }, configs: { @@ -22,6 +24,7 @@ export const vanillaExtract = { rules: { 'vanilla-extract/concentric-order': 'error', 'vanilla-extract/no-empty-style-blocks': 'error', + 'vanilla-extract/no-unknown-unit': 'error', 'vanilla-extract/no-zero-unit': 'error', }, },