From f880c051ffd3fd9e592f1d8bad9dfbc9bed2cc44 Mon Sep 17 00:00:00 2001 From: Ante Budimir Date: Wed, 16 Apr 2025 09:43:06 +0300 Subject: [PATCH] =?UTF-8?q?feat=20=F0=9F=A5=81:=20add=20no-unknown-unit=20?= =?UTF-8?q?rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a rule to disallow unknown or invalid CSS units in vanilla-extract style objects. - Reports any usage of unrecognized units in property values - Handles all vanilla-extract APIs (style, styleVariants, recipe, etc.) - Ignores valid units in special contexts (e.g., CSS functions, custom properties) No autofix is provided because replacing or removing unknown units may result in unintended or invalid CSS. Manual developer review is required to ensure correctness. --- CHANGELOG.md | 9 + README.md | 44 ++- package.json | 2 +- .../no-empty-blocks/__tests__/globals.test.ts | 1 - .../__tests__/no-unknown-unit.test.ts | 292 ++++++++++++++++++ src/css-rules/no-unknown-unit/index.ts | 3 + .../no-unknown-unit/rule-definition.ts | 22 ++ .../no-unknown-unit/unknown-unit-processor.ts | 196 ++++++++++++ .../unknown-unit-visitor-creator.ts | 52 ++++ src/css-rules/no-zero-unit/rule-definition.ts | 2 +- src/index.ts | 5 +- 11 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 src/css-rules/no-unknown-unit/__tests__/no-unknown-unit.test.ts create mode 100644 src/css-rules/no-unknown-unit/index.ts create mode 100644 src/css-rules/no-unknown-unit/rule-definition.ts create mode 100644 src/css-rules/no-unknown-unit/unknown-unit-processor.ts create mode 100644 src/css-rules/no-unknown-unit/unknown-unit-visitor-creator.ts 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', }, },