• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

antebudimir / eslint-plugin-vanilla-extract / 14486450276

16 Apr 2025 06:45AM UTC coverage: 99.254% (-0.2%) from 99.407%
14486450276

push

github

antebudimir
feat 🥁: add no-unknown-unit rule

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.

451 of 459 branches covered (98.26%)

Branch coverage included in aggregate %.

198 of 200 new or added lines in 4 files covered. (99.0%)

1811 of 1820 relevant lines covered (99.51%)

509.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.16
/src/css-rules/no-unknown-unit/unknown-unit-processor.ts
1
import type { Rule } from 'eslint';
2
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
1✔
3

4
/**
5
 * List of valid CSS units according to CSS specifications.
6
 */
7
const VALID_CSS_UNITS = [
1✔
8
  // Absolute length units
9
  'px',
1✔
10
  'cm',
1✔
11
  'mm',
1✔
12
  'Q',
1✔
13
  'in',
1✔
14
  'pc',
1✔
15
  'pt',
1✔
16
  // Relative length units
17
  'em',
1✔
18
  'ex',
1✔
19
  'ch',
1✔
20
  'rem',
1✔
21
  'lh',
1✔
22
  'rlh',
1✔
23
  'vw',
1✔
24
  'vh',
1✔
25
  'vmin',
1✔
26
  'vmax',
1✔
27
  'vb',
1✔
28
  'vi',
1✔
29
  'svw',
1✔
30
  'svh',
1✔
31
  'lvw',
1✔
32
  'lvh',
1✔
33
  'dvw',
1✔
34
  'dvh',
1✔
35
  // Percentage
36
  '%',
1✔
37
  // Angle units
38
  'deg',
1✔
39
  'grad',
1✔
40
  'rad',
1✔
41
  'turn',
1✔
42
  // Time units
43
  'ms',
1✔
44
  's',
1✔
45
  // Frequency units
46
  'Hz',
1✔
47
  'kHz',
1✔
48
  // Resolution units
49
  'dpi',
1✔
50
  'dpcm',
1✔
51
  'dppx',
1✔
52
  'x',
1✔
53
  // Flexible length units
54
  'fr',
1✔
55
  // Other valid units
56
  'cap',
1✔
57
  'ic',
1✔
58
  'rex',
1✔
59
  'cqw',
1✔
60
  'cqh',
1✔
61
  'cqi',
1✔
62
  'cqb',
1✔
63
  'cqmin',
1✔
64
  'cqmax',
1✔
65
];
1✔
66

67
/**
68
 * Regular expression to extract units from CSS values.
69
 * Matches numeric values followed by a unit.
70
 */
71
const CSS_VALUE_WITH_UNIT_REGEX = /^(-?\d*\.?\d+)([a-zA-Z%]+)$/i;
1✔
72

73
/**
74
 * Splits a CSS value string into individual parts, handling spaces not inside functions.
75
 */
76
const splitCssValues = (value: string): string[] => {
1✔
77
  return value
62✔
78
    .split(/(?<!\([^)]*)\s+/) // Split on spaces not inside functions
62✔
79
    .map((part) => part.trim())
62✔
80
    .filter((part) => part.length > 0);
62✔
81
};
62✔
82

83
/**
84
 * Check if a CSS value contains a valid CSS unit.
85
 */
86
const checkCssUnit = (
1✔
87
  value: string,
62✔
88
): { hasUnit: boolean; unit: string | null; isValid: boolean; invalidValue?: string } => {
62✔
89
  const values = splitCssValues(value);
62✔
90

91
  for (const value of values) {
62✔
92
    // Skip values containing CSS functions
93
    if (value.includes('(')) {
70✔
94
      continue;
6✔
95
    }
6✔
96

97
    const match = value.match(CSS_VALUE_WITH_UNIT_REGEX);
64✔
98
    if (!match) {
70✔
99
      continue;
6✔
100
    }
6✔
101

102
    const unit = match[2]!.toLowerCase(); // match[2] is guaranteed by regex pattern
58✔
103
    if (!VALID_CSS_UNITS.includes(unit)) {
70✔
104
      return {
22✔
105
        hasUnit: true,
22✔
106
        unit: match[2]!, // Preserve original casing
22✔
107
        isValid: false,
22✔
108
        invalidValue: value,
22✔
109
      };
22✔
110
    }
22✔
111
  }
70✔
112

113
  return { hasUnit: false, unit: null, isValid: true };
40✔
114
};
40✔
115

116
/**
117
 * Extracts string value from a node if it's a string literal or template literal.
118
 */
119
const getStringValue = (node: TSESTree.Node): string | null => {
1✔
120
  if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') {
78✔
121
    return node.value;
60✔
122
  }
60✔
123

124
  if (node.type === AST_NODE_TYPES.TemplateLiteral && node.quasis.length === 1) {
78✔
125
    const firstQuasi = node.quasis[0];
2✔
126
    return firstQuasi?.value.raw ? firstQuasi.value.raw : null;
2!
127
  }
2✔
128

129
  return null;
16✔
130
};
16✔
131

132
/**
133
 * Recursively processes a style object, reporting instances of
134
 * unknown CSS units.
135
 *
136
 * @param context The ESLint rule context.
137
 * @param node The ObjectExpression node representing the style object to be
138
 *   processed.
139
 */
140
export const processUnknownUnitInStyleObject = (context: Rule.RuleContext, node: TSESTree.ObjectExpression): void => {
1✔
141
  // Defensive: This function is only called with ObjectExpression nodes by the rule visitor.
142
  // This check's for type safety and future-proofing. It's not covered by rule tests
143
  // because the rule architecture prevents non-ObjectExpression nodes from reaching here.
144
  if (!node || node.type !== AST_NODE_TYPES.ObjectExpression) {
52!
NEW
145
    return;
×
NEW
146
  }
×
147

148
  for (const property of node.properties) {
52✔
149
    if (property.type !== AST_NODE_TYPES.Property) {
92✔
150
      continue;
4✔
151
    }
4✔
152

153
    // Get property key name if possible
154
    let propertyName: string | null = null;
88✔
155
    if (property.key.type === AST_NODE_TYPES.Identifier) {
92✔
156
      propertyName = property.key.name;
82✔
157
    } else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === 'string') {
92✔
158
      propertyName = property.key.value;
6✔
159
    }
6✔
160

161
    if (propertyName === '@media' || propertyName === 'selectors') {
92✔
162
      if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
10✔
163
        for (const nestedProperty of property.value.properties) {
8✔
164
          if (
8✔
165
            nestedProperty.type === AST_NODE_TYPES.Property &&
8✔
166
            nestedProperty.value.type === AST_NODE_TYPES.ObjectExpression
8✔
167
          ) {
8✔
168
            processUnknownUnitInStyleObject(context, nestedProperty.value);
8✔
169
          }
8✔
170
        }
8✔
171
      }
8✔
172
      continue;
10✔
173
    }
10✔
174

175
    // Process direct string values
176
    const value = getStringValue(property.value);
78✔
177
    if (value) {
92✔
178
      const result = checkCssUnit(value);
62✔
179
      if (result.hasUnit && !result.isValid && result.invalidValue) {
62✔
180
        context.report({
22✔
181
          node: property.value as Rule.Node,
22✔
182
          messageId: 'unknownUnit',
22✔
183
          data: {
22✔
184
            unit: result.unit || '',
22!
185
            value: result.invalidValue,
22✔
186
          },
22✔
187
        });
22✔
188
      }
22✔
189
    }
62✔
190

191
    // Process nested objects (including those not handled by special cases)
192
    if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
92✔
193
      processUnknownUnitInStyleObject(context, property.value);
2✔
194
    }
2✔
195
  }
92✔
196
};
52✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc