diff --git a/.gitignore b/.gitignore index 581d8f6..fd03fa9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ pnpm-debug.log* # typescript *.tsbuildinfo + +# dev helper +src/css-sample/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac51c7..d53d151 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.8.0] - 2025-04-12 + +- add new rule `no-zero-unit` that enforces unitless zero values in vanilla-extract style objects + - Automatically removes unnecessary units from zero values (e.g., '0px' → '0') + - Handles both positive and negative zero values + - Preserves units where required (time properties, CSS functions) + - Works with all vanilla-extract APIs including style, recipe, fontFace, and keyframes + - Supports nested objects, media queries, and pseudo-selectors + ## [1.7.0] - 2025-04-07 - add a recommended configuration preset that enables concentric-order and no-empty-style-blocks rules with error severity. diff --git a/README.md b/README.md index 9680823..3086400 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ export default [ // Optionally override specific rules // 'vanilla-extract/concentric-order': 'warn', // Change severity from error to warn // 'vanilla-extract/no-empty-style-blocks': 'off', // Disable a recommended rule + // 'vanilla-extract/no-zero-unit': 'warn', // Change severity from error to warn // Add additional rules not in recommended config // 'vanilla-extract/alphabetical-order': 'error', // Override concentric-order rule @@ -80,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-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. @@ -108,6 +110,7 @@ export default [ sortRemainingProperties: 'concentric', // 'alphabetical' is default }, ], + 'vanilla-extract/no-zero-unit': 'warn', }, }, ]; @@ -266,6 +269,34 @@ export const recipeWithEmptyVariants = recipe({ }); ``` +## 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. + +```typescript +// ❌ Incorrect +import { style } from '@vanilla-extract/css'; + +export const myStyle = style({ + margin: '0px', + padding: '0rem', + width: '0%', + height: '0vh', + top: '-0em', +}); + +// ✅ Correct +import { style } from '@vanilla-extract/css'; + +export const myStyle = style({ + margin: '0', + padding: '0', + width: '0', + height: '0', + top: '0', +}); +``` + ## Font Face Declarations For `fontFace` and `globalFontFace` API calls, all three ordering rules (alphabetical, concentric, and custom) enforce the same special ordering: @@ -341,16 +372,16 @@ The roadmap outlines the project's current status and future plans: - Support for multiple vanilla-extract APIs (e.g., `style`, `styleVariants`, `recipe`, `globalStyle`, `fontFace`, etc.). - `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. - Comprehensive rule testing. ### Current Work -- `no-zero-unit` rule to disallow units when the value is zero. +- `no-unknown-unit` rule to disallow unknown units. ### Upcoming Features -- `no-unknown-units` rule to disallow unknown units. -- `no-number-trailing-zeros` rule to disallow trailing zeros in numbers. +- `no-number-trailing-zero` rule to disallow trailing zeros in numbers. - `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 use of theme tokens instead of hard-coded values when available. diff --git a/package.json b/package.json index d35b1a9..14de43e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antebudimir/eslint-plugin-vanilla-extract", - "version": "1.7.0", + "version": "1.8.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/conditional-processor.ts b/src/css-rules/no-empty-blocks/conditional-processor.ts index 3996813..c7f1220 100644 --- a/src/css-rules/no-empty-blocks/conditional-processor.ts +++ b/src/css-rules/no-empty-blocks/conditional-processor.ts @@ -6,12 +6,12 @@ import { reportEmptyDeclaration } from './fix-utils.js'; /** * Handles conditional expressions with empty objects. */ -export function processConditionalExpression( +export const processConditionalExpression = ( context: Rule.RuleContext, node: TSESTree.ConditionalExpression, reportedNodes: Set, callNode: TSESTree.CallExpression, -): void { +): void => { const isConsequentEmpty = node.consequent.type === 'ObjectExpression' && isEmptyObject(node.consequent); const isAlternateEmpty = node.alternate.type === 'ObjectExpression' && isEmptyObject(node.alternate); @@ -33,4 +33,4 @@ export function processConditionalExpression( messageId: 'emptyConditionalStyle', }); } -} +}; diff --git a/src/css-rules/no-empty-blocks/empty-nested-style-processor.ts b/src/css-rules/no-empty-blocks/empty-nested-style-processor.ts index 9536e7f..ea65089 100644 --- a/src/css-rules/no-empty-blocks/empty-nested-style-processor.ts +++ b/src/css-rules/no-empty-blocks/empty-nested-style-processor.ts @@ -7,11 +7,11 @@ import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js'; /** * Processes nested style objects like selectors and media queries. */ -export function processEmptyNestedStyles( +export const processEmptyNestedStyles = ( ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression, reportedNodes: Set, -): void { +): void => { node.properties.forEach((property) => { if (property.type !== 'Property') { return; @@ -72,4 +72,4 @@ export function processEmptyNestedStyles( }); } }); -} +}; diff --git a/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts b/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts index ff9820f..643f36f 100644 --- a/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts +++ b/src/css-rules/no-empty-blocks/empty-style-visitor-creator.ts @@ -9,128 +9,10 @@ import { getStyleKeyName } from './property-utils.js'; import { processRecipeProperties } from './recipe-processor.js'; import { processStyleVariants } from './style-variants-processor.js'; -/** - * Creates ESLint rule visitors for detecting empty style blocks in vanilla-extract. - * @param ruleContext The ESLint rule rule context. - * @returns An object with visitor functions for the ESLint rule. - */ -export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.RuleListener => { - // Track reported nodes to prevent duplicate reports - const reportedNodes = new Set(); - - return { - CallExpression(node) { - if (node.callee.type !== 'Identifier') { - return; - } - - // Target vanilla-extract style functions - const styleApiFunctions = [ - 'style', - 'styleVariants', - 'recipe', - 'globalStyle', - 'fontFace', - 'globalFontFace', - 'keyframes', - 'globalKeyframes', - ]; - - if (!styleApiFunctions.includes(node.callee.name) || node.arguments.length === 0) { - return; - } - - // Handle styleVariants specifically - if (node.callee.name === 'styleVariants' && node.arguments[0]?.type === 'ObjectExpression') { - processStyleVariants(ruleContext, node.arguments[0] as TSESTree.ObjectExpression, reportedNodes); - - // If the entire styleVariants object is empty after processing, remove the declaration - if (isEmptyObject(node.arguments[0] as TSESTree.ObjectExpression)) { - reportEmptyDeclaration(ruleContext, node.arguments[0] as TSESTree.Node, node as TSESTree.CallExpression); - } - return; - } - - const defaultStyleArgumentIndex = 0; - const globalFunctionNames = ['globalStyle', 'globalFontFace', 'globalKeyframes']; - // Determine the style argument index based on the function name - const styleArgumentIndex = globalFunctionNames.includes(node.callee.name) ? 1 : defaultStyleArgumentIndex; - - // For global functions, check if we have enough arguments - if (styleArgumentIndex === 1 && node.arguments.length <= styleArgumentIndex) { - return; - } - - const styleArgument = node.arguments[styleArgumentIndex]; - - // This defensive check prevents duplicate processing of nodes. - // This code path's difficult to test because the ESLint visitor pattern - // typically ensures each node is only visited once per rule execution. - if (reportedNodes.has(styleArgument as TSESTree.Node)) { - return; - } - - // Handle conditional expressions - if (styleArgument?.type === 'ConditionalExpression') { - processConditionalExpression( - ruleContext, - styleArgument as TSESTree.ConditionalExpression, - reportedNodes, - node as TSESTree.CallExpression, - ); - return; - } - - // Direct empty object case - remove the entire declaration - if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument as TSESTree.ObjectExpression)) { - reportedNodes.add(styleArgument as TSESTree.ObjectExpression); - reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression); - return; - } - - // For recipe - check if entire recipe is effectively empty - if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') { - if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) { - reportedNodes.add(styleArgument as TSESTree.ObjectExpression); - reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression); - return; - } - - // Process individual properties in recipe - processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes); - } - - // For style objects with nested empty objects - if (styleArgument?.type === 'ObjectExpression') { - // Check for spread elements - styleArgument.properties.forEach((property) => { - if ( - property.type === 'SpreadElement' && - property.argument.type === 'ObjectExpression' && - isEmptyObject(property.argument as TSESTree.ObjectExpression) - ) { - reportedNodes.add(property.argument as TSESTree.Node); - ruleContext.report({ - node: property.argument as Rule.Node, - messageId: 'emptySpreadObject', - fix(fixer) { - return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer); - }, - }); - } - }); - - // Process nested selectors and media queries - processEmptyNestedStyles(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes); - } - }, - }; -}; - /** * Checks if a style object is effectively empty (contains only empty objects). */ -export function isEffectivelyEmptyStylesObject(stylesObject: TSESTree.ObjectExpression): boolean { +export const isEffectivelyEmptyStylesObject = (stylesObject: TSESTree.ObjectExpression): boolean => { // Empty object itself if (stylesObject.properties.length === 0) { return true; @@ -225,4 +107,122 @@ export function isEffectivelyEmptyStylesObject(stylesObject: TSESTree.ObjectExpr // If we have special properties and they're all empty, the style is effectively empty return specialProperties.length > 0 && allSpecialPropertiesEmpty; -} +}; + +/** + * Creates ESLint rule visitors for detecting empty style blocks in vanilla-extract. + * @param ruleContext The ESLint rule rule context. + * @returns An object with visitor functions for the ESLint rule. + */ +export const createEmptyStyleVisitors = (ruleContext: Rule.RuleContext): Rule.RuleListener => { + // Track reported nodes to prevent duplicate reports + const reportedNodes = new Set(); + + return { + CallExpression(node) { + if (node.callee.type !== 'Identifier') { + return; + } + + // Target vanilla-extract style functions + const styleApiFunctions = [ + 'style', + 'styleVariants', + 'recipe', + 'globalStyle', + 'fontFace', + 'globalFontFace', + 'keyframes', + 'globalKeyframes', + ]; + + if (!styleApiFunctions.includes(node.callee.name) || node.arguments.length === 0) { + return; + } + + // Handle styleVariants specifically + if (node.callee.name === 'styleVariants' && node.arguments[0]?.type === 'ObjectExpression') { + processStyleVariants(ruleContext, node.arguments[0] as TSESTree.ObjectExpression, reportedNodes); + + // If the entire styleVariants object is empty after processing, remove the declaration + if (isEmptyObject(node.arguments[0] as TSESTree.ObjectExpression)) { + reportEmptyDeclaration(ruleContext, node.arguments[0] as TSESTree.Node, node as TSESTree.CallExpression); + } + return; + } + + const defaultStyleArgumentIndex = 0; + const globalFunctionNames = ['globalStyle', 'globalFontFace', 'globalKeyframes']; + // Determine the style argument index based on the function name + const styleArgumentIndex = globalFunctionNames.includes(node.callee.name) ? 1 : defaultStyleArgumentIndex; + + // For global functions, check if we have enough arguments + if (styleArgumentIndex === 1 && node.arguments.length <= styleArgumentIndex) { + return; + } + + const styleArgument = node.arguments[styleArgumentIndex]; + + // This defensive check prevents duplicate processing of nodes. + // This code path's difficult to test because the ESLint visitor pattern + // typically ensures each node is only visited once per rule execution. + if (reportedNodes.has(styleArgument as TSESTree.ObjectExpression)) { + return; + } + + // Handle conditional expressions + if (styleArgument?.type === 'ConditionalExpression') { + processConditionalExpression( + ruleContext, + styleArgument as TSESTree.ConditionalExpression, + reportedNodes, + node as TSESTree.CallExpression, + ); + return; + } + + // Direct empty object case - remove the entire declaration + if (styleArgument?.type === 'ObjectExpression' && isEmptyObject(styleArgument as TSESTree.ObjectExpression)) { + reportedNodes.add(styleArgument as TSESTree.ObjectExpression); + reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression); + return; + } + + // For recipe - check if entire recipe is effectively empty + if (node.callee.name === 'recipe' && styleArgument?.type === 'ObjectExpression') { + if (isEffectivelyEmptyStylesObject(styleArgument as TSESTree.ObjectExpression)) { + reportedNodes.add(styleArgument as TSESTree.ObjectExpression); + reportEmptyDeclaration(ruleContext, styleArgument as TSESTree.Node, node as TSESTree.CallExpression); + return; + } + + // Process individual properties in recipe + processRecipeProperties(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes); + } + + // For style objects with nested empty objects + if (styleArgument?.type === 'ObjectExpression') { + // Check for spread elements + styleArgument.properties.forEach((property) => { + if ( + property.type === 'SpreadElement' && + property.argument.type === 'ObjectExpression' && + isEmptyObject(property.argument as TSESTree.ObjectExpression) + ) { + reportedNodes.add(property.argument as TSESTree.ObjectExpression); + ruleContext.report({ + node: property.argument as Rule.Node, + messageId: 'emptySpreadObject', + fix(fixer) { + return removeNodeWithComma(ruleContext, property as TSESTree.Node, fixer); + }, + }); + } + }); + + // Process nested selectors and media queries + processEmptyNestedStyles(ruleContext, styleArgument as TSESTree.ObjectExpression, reportedNodes); + } + }, + }; +}; diff --git a/src/css-rules/no-empty-blocks/node-remover.ts b/src/css-rules/no-empty-blocks/node-remover.ts index 28971e2..88c5437 100644 --- a/src/css-rules/no-empty-blocks/node-remover.ts +++ b/src/css-rules/no-empty-blocks/node-remover.ts @@ -8,11 +8,11 @@ import type { TSESTree } from '@typescript-eslint/utils'; * @param fixer The ESLint fixer. * @returns The fix object. */ -export function removeNodeWithComma(ruleContext: Rule.RuleContext, node: TSESTree.Node, fixer: Rule.RuleFixer) { +export const removeNodeWithComma = (ruleContext: Rule.RuleContext, node: TSESTree.Node, fixer: Rule.RuleFixer) => { const sourceCode = ruleContext.sourceCode; const tokenAfter = sourceCode.getTokenAfter(node as Rule.Node); if (tokenAfter && tokenAfter.value === ',' && node.range && tokenAfter.range) { return fixer.removeRange([node.range[0], tokenAfter.range[1]]); } return fixer.remove(node as Rule.Node); -} +}; diff --git a/src/css-rules/no-empty-blocks/property-utils.ts b/src/css-rules/no-empty-blocks/property-utils.ts index 720a1bf..0839074 100644 --- a/src/css-rules/no-empty-blocks/property-utils.ts +++ b/src/css-rules/no-empty-blocks/property-utils.ts @@ -4,7 +4,7 @@ import { isEmptyObject } from '../shared-utils/empty-object-processor.js'; /** * Gets the property name regardless of whether it's an identifier or a literal. */ -export function getStyleKeyName(key: TSESTree.Expression | TSESTree.PrivateIdentifier): string | null { +export const getStyleKeyName = (key: TSESTree.Expression | TSESTree.PrivateIdentifier): string | null => { if (key.type === 'Identifier') { return key.name; } @@ -12,7 +12,7 @@ export function getStyleKeyName(key: TSESTree.Expression | TSESTree.PrivateIdent return key.value; } return null; -} +}; /** * Checks if all properties in a style object are empty objects. diff --git a/src/css-rules/no-empty-blocks/recipe-processor.ts b/src/css-rules/no-empty-blocks/recipe-processor.ts index e593da6..15c8470 100644 --- a/src/css-rules/no-empty-blocks/recipe-processor.ts +++ b/src/css-rules/no-empty-blocks/recipe-processor.ts @@ -13,11 +13,11 @@ import { areAllChildrenEmpty, getStyleKeyName } from './property-utils.js'; * @param recipeNode The recipe object node to process. * @param reportedNodes A set of nodes that have already been reported by other processors. */ -export function processRecipeProperties( +export const processRecipeProperties = ( ruleContext: Rule.RuleContext, recipeNode: TSESTree.ObjectExpression, reportedNodes: Set, -): void { +): void => { recipeNode.properties.forEach((property) => { if (property.type !== 'Property') { return; @@ -139,4 +139,4 @@ export function processRecipeProperties( } } }); -} +}; diff --git a/src/css-rules/no-empty-blocks/style-variants-processor.ts b/src/css-rules/no-empty-blocks/style-variants-processor.ts index 8992a2b..cbc7552 100644 --- a/src/css-rules/no-empty-blocks/style-variants-processor.ts +++ b/src/css-rules/no-empty-blocks/style-variants-processor.ts @@ -10,11 +10,11 @@ import { removeNodeWithComma } from './node-remover.js'; * @param node The styleVariants call argument (object expression). * @param reportedNodes A set of nodes that have already been reported. */ -export function processStyleVariants( +export const processStyleVariants = ( ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression, reportedNodes: Set, -): void { +): void => { node.properties.forEach((property) => { if (property.type !== 'Property') { return; @@ -50,4 +50,4 @@ export function processStyleVariants( return; } }); -} +}; diff --git a/src/css-rules/no-zero-unit/_tests_/no-zero-unit.test.ts b/src/css-rules/no-zero-unit/_tests_/no-zero-unit.test.ts new file mode 100644 index 0000000..0bf9452 --- /dev/null +++ b/src/css-rules/no-zero-unit/_tests_/no-zero-unit.test.ts @@ -0,0 +1,341 @@ +import tsParser from '@typescript-eslint/parser'; +import { run } from 'eslint-vitest-rule-tester'; +import noZeroUnitRule from '../rule-definition.js'; + +run({ + name: 'vanilla-extract/no-zero-unit', + rule: noZeroUnitRule, + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + valid: [ + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0', + padding: 0, + width: '100%', + }); + `, + }, + { + code: ` + import { recipe } from '@vanilla-extract/css'; + const myRecipe = recipe({ + base: { + margin: '0', + padding: 0, + }, + variants: { + size: { + small: { + height: '0', + width: '10px', + }, + }, + }, + }); + `, + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + ...spreadProps, + margin: 0, + '@media': { + '0rem': '0' // Key shouldn't be checked + } + }); + `, + name: 'should ignore spread elements and object keys', + }, + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: \`0\${someUnit}\`, // Template literal + padding: someVariable + }); + `, + name: 'should ignore non-literal values', + }, + + { + code: ` + import { globalStyle } from '@vanilla-extract/css'; + const callExpression = someObject.fontFace({ src: '...' }); // Non-Identifier callee + `, + name: 'should ignore member expression callees', + }, + { + code: ` + import { fontFace } from '@vanilla-extract/css'; + fontFace(); // Missing arguments + `, + name: 'should handle missing fontFace arguments', + }, + { + code: ` + import { globalFontFace } from '@vanilla-extract/css'; + globalFontFace('my-font'); // Missing style argument + `, + name: 'should handle missing globalFontFace style argument', + }, + ], + invalid: [ + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0px', + padding: '0rem', + }); + `, + errors: [{ messageId: 'noZeroUnit' }, { messageId: 'noZeroUnit' }], + output: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0', + padding: '0', + }); + `, + }, + { + code: ` + import { recipe } from '@vanilla-extract/css'; + const myRecipe = recipe({ + base: { + margin: '0px', + }, + variants: { + size: { + small: { + height: '0vh', + }, + }, + }, + }); + `, + errors: [{ messageId: 'noZeroUnit' }, { messageId: 'noZeroUnit' }], + output: ` + import { recipe } from '@vanilla-extract/css'; + const myRecipe = recipe({ + base: { + margin: '0', + }, + variants: { + size: { + small: { + height: '0', + }, + }, + }, + }); + `, + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0px', + '@media': { + '(min-width: 768px)': { + padding: '0rem' + } + } + }); + `, + errors: 2, + output: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0', + '@media': { + '(min-width: 768px)': { + padding: '0' + } + } + }); + `, + name: 'should handle nested media queries', + }, + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + '::before': { + content: '""', + margin: '0px' + } + }); + `, + errors: 1, + output: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + '::before': { + content: '""', + margin: '0' + } + }); + `, + name: 'should handle pseudo-elements', + }, + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0px', + nested: { + object: { + padding: '0rem', + deeper: { + width: '0%' + } + } + } + }); + `, + errors: 3, + output: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0', + nested: { + object: { + padding: '0', + deeper: { + width: '0' + } + } + } + }); + `, + name: 'should handle multiple levels of nesting', + }, + + { + code: ` + import { fontFace, globalFontFace } from '@vanilla-extract/css'; + + fontFace({ + src: '...', + lineGap: '0rem' + }); + + globalFontFace('my-font', { + src: '...', + sizeAdjust: '0%' + }); + `, + errors: 2, + output: ` + import { fontFace, globalFontFace } from '@vanilla-extract/css'; + + fontFace({ + src: '...', + lineGap: '0' + }); + + globalFontFace('my-font', { + src: '...', + sizeAdjust: '0' + }); + `, + name: 'should handle fontFace and globalFontFace arguments', + }, + + // 0deg is valid (deg isn't in our unit check) + { + code: ` + import { globalKeyframes, globalStyle } from '@vanilla-extract/css'; + + globalKeyframes('spin', { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(0deg)' } + }); + + globalStyle('html', { + margin: '0px', + padding: '0rem' + }); + `, + errors: 2, + output: ` + import { globalKeyframes, globalStyle } from '@vanilla-extract/css'; + + globalKeyframes('spin', { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(0deg)' } + }); + + globalStyle('html', { + margin: '0', + padding: '0' + }); + `, + name: 'should handle globalKeyframes and globalStyle arguments', + }, + + { + code: ` + import { globalStyle } from '@vanilla-extract/css'; + globalStyle('html', { + '@media': { + '(min-width: 768px)': { + margin: '0px' + } + } + }); + `, + errors: 1, + output: ` + import { globalStyle } from '@vanilla-extract/css'; + globalStyle('html', { + '@media': { + '(min-width: 768px)': { + margin: '0' + } + } + }); + `, + name: 'should handle nested globalStyle arguments', + }, + + { + code: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '-0px', + padding: '-0rem', + top: '-0vh', + left: '-0%', + }); + `, + errors: [ + { messageId: 'noZeroUnit' }, + { messageId: 'noZeroUnit' }, + { messageId: 'noZeroUnit' }, + { messageId: 'noZeroUnit' }, + ], + output: ` + import { style } from '@vanilla-extract/css'; + const myStyle = style({ + margin: '0', + padding: '0', + top: '0', + left: '0', + }); + `, + name: 'should convert negative zero with units to simple zero', + }, + ], +}); diff --git a/src/css-rules/no-zero-unit/index.ts b/src/css-rules/no-zero-unit/index.ts new file mode 100644 index 0000000..606424c --- /dev/null +++ b/src/css-rules/no-zero-unit/index.ts @@ -0,0 +1,3 @@ +import noZeroUnitRule from './rule-definition.js'; + +export default noZeroUnitRule; diff --git a/src/css-rules/no-zero-unit/rule-definition.ts b/src/css-rules/no-zero-unit/rule-definition.ts new file mode 100644 index 0000000..698674a --- /dev/null +++ b/src/css-rules/no-zero-unit/rule-definition.ts @@ -0,0 +1,23 @@ +import type { Rule } from 'eslint'; +import { createZeroUnitVisitors } from './zero-unit-visitor-creator.js'; + +const noZeroUnitRule: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: 'enforce unitless zero in numeric values', + category: 'Stylistic Issues', + recommended: true, + }, + fixable: 'code', + schema: [], + messages: { + noZeroUnit: 'Unit with zero value is unnecessary. Use 0 instead.', + }, + }, + create(context) { + return createZeroUnitVisitors(context); + }, +}; + +export default noZeroUnitRule; diff --git a/src/css-rules/no-zero-unit/zero-unit-processor.ts b/src/css-rules/no-zero-unit/zero-unit-processor.ts new file mode 100644 index 0000000..e06dfd2 --- /dev/null +++ b/src/css-rules/no-zero-unit/zero-unit-processor.ts @@ -0,0 +1,36 @@ +import type { Rule } from 'eslint'; +import { TSESTree } from '@typescript-eslint/utils'; + +const ZERO_VALUE_WITH_UNIT_REGEX = /^-?0(px|em|rem|%|vh|vw|vmin|vmax|ex|ch|cm|mm|in|pt|pc|Q|fr)$/; + +/** + * Recursively processes a style object, reporting and fixing instances of zero values with units. + * + * @param ruleContext The ESLint rule context. + * @param node The ObjectExpression node representing the style object to be processed. + */ +export const processZeroUnitInStyleObject = (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression): void => { + node.properties.forEach((property) => { + if (property.type !== 'Property') { + return; + } + + // Process direct string literal values + if ( + property.value.type === 'Literal' && + typeof property.value.value === 'string' && + ZERO_VALUE_WITH_UNIT_REGEX.test(property.value.value) + ) { + ruleContext.report({ + node: property.value, + messageId: 'noZeroUnit', + fix: (fixer) => fixer.replaceText(property.value, "'0'"), + }); + } + + // Process nested objects (selectors, media queries, etc.) + if (property.value.type === 'ObjectExpression') { + processZeroUnitInStyleObject(ruleContext, property.value); + } + }); +}; diff --git a/src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts b/src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts new file mode 100644 index 0000000..f1c5004 --- /dev/null +++ b/src/css-rules/no-zero-unit/zero-unit-visitor-creator.ts @@ -0,0 +1,56 @@ +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 { processZeroUnitInStyleObject } from './zero-unit-processor.js'; + +/** + * Creates ESLint rule visitors for detecting and processing zero values with units in style-related function calls. + * + * @param context The ESLint rule context. + * @returns An object with visitor functions for the ESLint rule. + * + * This function sets up visitors for the following cases: + * 1. The `fontFace` and `globalFontFace` functions, processing their object arguments. + * 2. Style-related functions: `keyframes`, `style`, `styleVariants`, processing their style objects. + * 3. The `globalKeyframes` and `globalStyle` functions, processing the second argument as style objects. + * 4. The `recipe` function, processing the first argument as the recipe object. + */ +export const createZeroUnitVisitors = (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 + ) { + processZeroUnitInStyleObject(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, processZeroUnitInStyleObject); + } + } + + if (['globalKeyframes', 'globalStyle'].includes(node.callee.name) && node.arguments.length >= 2) { + processStyleNode(context, node.arguments[1] as TSESTree.ObjectExpression, processZeroUnitInStyleObject); + } + + 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, processZeroUnitInStyleObject); + } + }, + }; +}; diff --git a/src/css-rules/shared-utils/order-strategy-visitor-creator.ts b/src/css-rules/shared-utils/order-strategy-visitor-creator.ts index a754aca..84c178c 100644 --- a/src/css-rules/shared-utils/order-strategy-visitor-creator.ts +++ b/src/css-rules/shared-utils/order-strategy-visitor-creator.ts @@ -44,13 +44,8 @@ export const createNodeVisitors = ( if (!userDefinedGroupOrder || userDefinedGroupOrder.length === 0) { return enforceAlphabeticalCSSOrderInStyleObject; } - return (ruleContext: Rule.RuleContext, node: TSESTree.Node) => - enforceUserDefinedGroupOrderInStyleObject( - ruleContext, - node as TSESTree.ObjectExpression, - userDefinedGroupOrder, - sortRemainingProperties, - ); + return (ruleContext: Rule.RuleContext, node: TSESTree.ObjectExpression) => + enforceUserDefinedGroupOrderInStyleObject(ruleContext, node, userDefinedGroupOrder, sortRemainingProperties); default: return enforceAlphabeticalCSSOrderInStyleObject; } diff --git a/src/css-sample/sample.css.ts b/src/css-sample/sample.css.ts deleted file mode 100644 index f752015..0000000 --- a/src/css-sample/sample.css.ts +++ /dev/null @@ -1,589 +0,0 @@ -import { - fontFace, - globalFontFace, - globalKeyframes, - globalStyle, - keyframes, - style, - styleVariants, -} from '@vanilla-extract/css'; -import { recipe } from '@vanilla-extract/recipes'; - -// fontFaces -export const theFont = fontFace({ - // Comment to test that the linter doesn't remove it - src: ['url("/fonts/MyFont.woff2") format("woff2")', 'url("/fonts/MyFont.woff") format("woff")'], - ascentOverride: '90%', - descentOverride: '10%', - fontDisplay: 'swap', - fontFeatureSettings: '"liga" 1', - fontStretch: 'normal', - fontStyle: 'normal', - fontVariant: 'normal', - fontVariationSettings: '"wght" 400', - fontWeight: '400 700', - lineGapOverride: '10%', - sizeAdjust: '90%', - unicodeRange: - 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD', -}); - -globalFontFace('GlobalFont', { - // Comment to test that the linter doesn't remove it - src: ['url("/fonts/MyFont.woff2") format("woff2")', 'url("/fonts/MyFont.woff") format("woff")'], - ascentOverride: '90%', - descentOverride: '10%', - fontDisplay: 'swap', - fontFeatureSettings: '"liga" 1', - fontStretch: 'normal', - fontStyle: 'normal', - fontVariant: 'normal', - fontVariationSettings: '"wght" 400', - fontWeight: '400 700', - lineGapOverride: '10%', - sizeAdjust: '90%', - unicodeRange: - 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD', -}); - -// keyframes -export const spinster = globalKeyframes('spin', { - // Comment to test that the linter doesn't remove it - from: { - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - - // Comment to test that the linter doesn't remove it - to: { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, -}); - -export const starter = keyframes({ - // Comment to test that the linter doesn't remove it - '0%': { - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - - // Comment to test that the linter doesn't remove it - '100%': { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, -}); - -globalStyle('*, ::before, ::after', { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', -}); - -// style with an array -const accordionContentBase = style([ - // Comment to test that the linter doesn't remove it - { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - - // special selector to test that the linter doesn't remove it - '@supports': { - '(hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none)': { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - }, - }, -]); - -export const accordionContent = recipe({ - // Comment to test that the linter doesn't remove it - base: accordionContentBase, - // Comment to test that the linter doesn't remove it - variants: { - // Comment to test that the linter doesn't remove it - isOpen: { - // Comment to test that the linter doesn't remove it - false: { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - - true: { - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - - // pseudo selector inside a variant - ':hover': { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - }, - }, - }, -}); - -export const item = style({ - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - - // pseudo selector inside a style - ':focus-visible': { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - - selectors: { - // Comment to test that the linter doesn't remove it - '&[data-pressed]': { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - }, -}); - -export const selectButtonVariants = styleVariants({ - // Comment to test that the linter doesn't remove it - bordered: { - // Comment to test that the linter doesn't remove it - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, - - borderless: { - width: '100%', - margin: '0', - fontSize: 'large', - border: 'Background', - borderRight: 'ActiveBorder', - borderLeft: 'ActiveBorder', - borderRadius: 'initial', - borderBottomLeftRadius: 'initial', - borderBottomRightRadius: 'initial', - boxShadow: 'none', - boxSizing: 'inherit', - position: 'relative', - right: 'inherit', - display: 'flex', - gap: 'revert', - transform: 'none', - outline: 'none', - backgroundColor: 'initial', - cursor: 'pointer', - color: 'red', - }, -}); - -// Test cases for noEmptyStyleBlocksRule - -// export const myRecipe = recipe({ -// base: { -// color: 'blue', -// selectors: {}, -// '@media': {}, -// '@supports': {}, -// }, -// variants: { -// size: { -// small: { -// selectors: { -// '&:hover': {}, -// }, -// '@media': { -// '(min-width: 768px)': {}, -// }, -// '@supports': { -// '(display: grid)': {}, -// }, -// }, -// }, -// }, -// }); - -// const base = style({ padding: 12 }); -// export const variant = styleVariants({ -// primary: [], -// secondary: [], -// bordered: {}, -// borderless: {}, -// }); - -// const baseStyles = { -// color: 'blue', -// margin: '10px', -// }; - -// const isDarkMode = false; - -// export const spreadStyle = style({ -// ...baseStyles, -// ...{}, -// }); - -// export const recipeWithNonObjectValue = recipe({ -// base: { color: 'black' }, -// variants: { -// color: { -// red: { color: 'red' }, -// // string instead of object -// string: 'string', -// // variable instead of object -// variable: baseStyles, -// }, -// }, -// }); - -// export const conditionalStyle = style(isDarkMode ? {} : {}); - -// export const recipeWithEmptyVariantValues = recipe({ -// base: { color: 'black' }, -// variants: { -// color: { -// blue: {}, -// red: {}, -// }, -// }, -// }); - -// export const nestedEmptyStyle = style({ -// selectors: { -// '&:hover': {}, -// '&:focus': {}, -// }, -// }); - -// const myEmptyStyle = style({}); -// export { myEmptyStyle }; - -// export const emptyStyle1 = style({}); -// export const emptyStyle2 = style({}); -// export const emptyVariants = styleVariants({}); -// export const emptyRecipe = recipe({}); - -// export const styleWithComments = style({ -// /* This is an empty style */ -// }); - -// export const styleWithEmptyMedia = style({ -// color: 'blue', -// '@media': { -// '(min-width: 768px)': {}, -// }, -// }); - -// export const styleWithEmptySelector = style({ -// color: 'blue', - -// selectors: { -// '&:hover': {}, -// }, -// }); - -// export const recipeWithBothEmpty = recipe({ -// base: {}, -// variants: {}, -// }); - -// export const recipeWithEmptyVariants = recipe({ -// base: { color: 'black' }, -// variants: {}, -// }); - -// export const recipeWithEmptyBase = recipe({ -// base: {}, -// variants: { -// color: { -// blue: { color: 'blue' }, -// }, -// }, -// }); - -// export const recipe = recipe({ -// base: {}, -// variants: { -// color: { -// red: {}, -// blue: {}, -// }, -// }, -// }); - -// export const recipeWithNonObjectVariants = recipe({ -// base: { color: 'blue' }, -// variants: { -// color: { -// size: 'string instead of object', // This is a string, not an object -// red: {}, -// }, -// }, -// }); - -// Using the same empty object reference in both branches -// export const myStyle = style(true ? {} : {}); - -// export const emptyFontFace = fontFace({}); -// globalFontFace('GlobalFont', {}); -// globalKeyframes('a', {}); -// export const emptyKeyframes = keyframes({}); -// globalStyle('ul', {}); -// export const emptyStyleVariants = styleVariants({}); -// export const emptyStyle = style({}); diff --git a/src/index.ts b/src/index.ts index 46750d6..942507e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,17 +2,19 @@ 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 noZeroUnitRule from './css-rules/no-zero-unit/rule-definition.js'; export const vanillaExtract = { meta: { name: '@antebudimir/eslint-plugin-vanilla-extract', - version: '1.7.0', + version: '1.8.0', }, rules: { 'alphabetical-order': alphabeticalOrderRule, 'concentric-order': concentricOrderRule, 'custom-order': customOrderRule, 'no-empty-style-blocks': noEmptyStyleBlocksRule, + 'no-zero-unit': noZeroUnitRule, }, configs: { recommended: { @@ -20,6 +22,7 @@ export const vanillaExtract = { rules: { 'vanilla-extract/concentric-order': 'error', 'vanilla-extract/no-empty-style-blocks': 'error', + 'vanilla-extract/no-zero-unit': 'error', }, }, }, diff --git a/tsconfig.json b/tsconfig.json index 5c8577a..9f6109f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,8 @@ "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "useUnknownInCatchVariables": true, // Interop Options