diff --git a/README.md b/README.md index 9927128..cd873f4 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,34 @@ export const myStyle = style({ }); ``` +## Font Face Declarations + +For `fontFace` and `globalFontFace` API calls, all three ordering rules (alphabetical, concentric, and custom) enforce the same special ordering: + +1. The `src` property always appears first +2. All remaining properties are sorted alphabetically + +This special handling is applied because: + +- The `src` property is the most critical property in font face declarations +- Consistent ordering improves readability for these specific APIs +- Font-related properties are specialized and benefit from standardized ordering + +```typescript +// ✅ Correct ordering for font faces +export const theFont = fontFace({ + src: ['url("/fonts/MyFont.woff2") format("woff2")', 'url("/fonts/MyFont.woff") format("woff")'], + ascentOverride: '90%', + descentOverride: '10%', + fontDisplay: 'swap', + fontFeatureSettings: '"liga" 1', + fontStretch: 'normal', + // ...other properties in alphabetical order +}); +``` + +Opinionated, but it is what it is. If someone has a suggestion for a better ordering, let me know! + ## Concentric CSS Model Here's a list of all available groups from the provided [concentricGroups](src/css-rules/concentric-order/concentric-groups.ts) array: @@ -224,22 +252,22 @@ The roadmap outlines the project's current status and future plans: - Initial release with support for alphabetical, concentric, and custom group CSS ordering. - Auto-fix capability integrated into ESLint. -- Support for multiple vanilla-extract APIs (e.g., `style`, `styleVariants`, `recipe`, `globalStyle` etc.). +- Support for multiple vanilla-extract APIs (e.g., `style`, `styleVariants`, `recipe`, `globalStyle`, `fontFace`, etc.). ### Current Work -- `fontFace` and `globalFontFace` linting support. +- Test coverage. ### Upcoming Features -- Test coverage. - `no-empty-blocks` rule to disallow empty blocks. - `no-unknown-units` rule to disallow unknown units. - `no-number-trailing-zeros` rule to disallow trailing zeros in numbers. - `no-zero-unit` rule to disallow units when the value is zero. -- `np-px-unit` rule to disallow use of `px` units with configurable whitelist. +- `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. +- `no-global-style` rule to disallow use of `globalStyle` function. - 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 9fc9285..a46b6ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antebudimir/eslint-plugin-vanilla-extract", - "version": "1.3.1", + "version": "1.4.0", "description": "ESLint plugin for enforcing CSS ordering in vanilla-extract styles", "author": "Ante Budimir", "license": "MIT", diff --git a/src/css-rules/alphabetical-order/property-order-enforcer.ts b/src/css-rules/alphabetical-order/property-order-enforcer.ts index 15dffd5..92d3dcd 100644 --- a/src/css-rules/alphabetical-order/property-order-enforcer.ts +++ b/src/css-rules/alphabetical-order/property-order-enforcer.ts @@ -1,23 +1,9 @@ import type { Rule } from 'eslint'; import { TSESTree } from '@typescript-eslint/utils'; +import { comparePropertiesAlphabetically } from '../shared-utils/alphabetical-property-comparator.js'; import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js'; import { getPropertyName } from '../shared-utils/property-separator.js'; -/** - * Compares two CSS properties alphabetically. - * @param firstProperty The first property to compare. - * @param secondProperty The second property to compare. - * @returns A number indicating the relative order of the properties (-1, 0, or 1). - */ -const comparePropertiesAlphabetically = ( - firstProperty: TSESTree.Property, - secondProperty: TSESTree.Property, -): number => { - const firstName = getPropertyName(firstProperty); - const secondName = getPropertyName(secondProperty); - return firstName.localeCompare(secondName); -}; - /** * Reports an ordering issue to ESLint and generates fixes. * @param ruleContext The ESLint rule context. diff --git a/src/css-rules/alphabetical-order/rule-definition.ts b/src/css-rules/alphabetical-order/rule-definition.ts index a24ce8c..ad2fe3f 100644 --- a/src/css-rules/alphabetical-order/rule-definition.ts +++ b/src/css-rules/alphabetical-order/rule-definition.ts @@ -12,7 +12,9 @@ const alphabeticalOrderRule: Rule.RuleModule = { fixable: 'code', schema: [], messages: { - alphabeticalOrder: "Property '{{next}}' should come before '{{current}}' in alphabetical order.", + alphabeticalOrder: "Property '{{nextProperty}}' should come before '{{currentProperty}}' in alphabetical order.", + fontFaceOrder: + "Properties in fontFace should be ordered with 'src' first, followed by other properties in alphabetical order. Property '{{nextProperty}}' should come before '{{currentProperty}}'.", }, }, create(context) { diff --git a/src/css-rules/concentric-order/property-order-enforcer.ts b/src/css-rules/concentric-order/property-order-enforcer.ts index 2105116..374321d 100644 --- a/src/css-rules/concentric-order/property-order-enforcer.ts +++ b/src/css-rules/concentric-order/property-order-enforcer.ts @@ -20,8 +20,8 @@ const reportOrderingIssue = ( node: nextProperty.node as Rule.Node, messageId: 'incorrectOrder', data: { - next: nextProperty.name, - current: currentProperty.name, + nextProperty: nextProperty.name, + currentProperty: currentProperty.name, }, fix: (fixer) => generateFixesForCSSOrder( diff --git a/src/css-rules/concentric-order/rule-definition.ts b/src/css-rules/concentric-order/rule-definition.ts index 88de724..d18ff53 100644 --- a/src/css-rules/concentric-order/rule-definition.ts +++ b/src/css-rules/concentric-order/rule-definition.ts @@ -8,11 +8,15 @@ const concentricOrderRule: Rule.RuleModule = { description: 'enforce concentric CSS property ordering in vanilla-extract styles', category: 'Stylistic Issues', recommended: true, + url: 'https://rhodesmill.org/brandon/2011/concentric-css/', }, fixable: 'code', schema: [], messages: { - incorrectOrder: "Property '{{next}}' should come before '{{current}}' according to concentric CSS ordering.", + incorrectOrder: + "Property '{{nextProperty}}' should come before '{{currentProperty}}' according to concentric CSS ordering.", + fontFaceOrder: + "Properties in fontFace should be ordered with 'src' first, followed by other properties in alphabetical order. Property '{{nextProperty}}' should come before '{{currentProperty}}'.", }, }, create(context) { diff --git a/src/css-rules/custom-order/rule-definition.ts b/src/css-rules/custom-order/rule-definition.ts index 5fac6e2..50159ab 100644 --- a/src/css-rules/custom-order/rule-definition.ts +++ b/src/css-rules/custom-order/rule-definition.ts @@ -36,6 +36,8 @@ const customGroupOrderRule: Rule.RuleModule = { messages: { incorrectOrder: "Property '{{nextProperty}}' should come before '{{currentProperty}}' according to custom CSS group ordering.", + fontFaceOrder: + "Properties in fontFace should be ordered with 'src' first, followed by other properties in alphabetical order. Property '{{nextProperty}}' should come before '{{currentProperty}}'.", }, }, create(ruleContext: Rule.RuleContext) { diff --git a/src/css-rules/shared-utils/alphabetical-property-comparator.ts b/src/css-rules/shared-utils/alphabetical-property-comparator.ts new file mode 100644 index 0000000..223af86 --- /dev/null +++ b/src/css-rules/shared-utils/alphabetical-property-comparator.ts @@ -0,0 +1,26 @@ +import { getPropertyName } from './property-separator.js'; +import type { TSESTree } from '@typescript-eslint/utils'; + +/** + * Compares two CSS properties alphabetically. + * @param firstProperty The first property to compare. + * @param secondProperty The second property to compare. + * @returns A number indicating the relative order of the properties (-1, 0, or 1). + */ +export const comparePropertiesAlphabetically = ( + firstProperty: TSESTree.Property, + secondProperty: TSESTree.Property, +): number => { + const firstName = getPropertyName(firstProperty); + const secondName = getPropertyName(secondProperty); + + // Special handling for 'src' property - it should always come first (relates to font face APIs only) + if (firstName === 'src') { + return -1; + } + if (secondName === 'src') { + return 1; + } + + return firstName.localeCompare(secondName); +}; diff --git a/src/css-rules/shared-utils/font-face-property-order-enforcer.ts b/src/css-rules/shared-utils/font-face-property-order-enforcer.ts new file mode 100644 index 0000000..b2ac590 --- /dev/null +++ b/src/css-rules/shared-utils/font-face-property-order-enforcer.ts @@ -0,0 +1,59 @@ +import type { Rule } from 'eslint'; +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js'; +import { getPropertyName, separateProperties } from '../shared-utils/property-separator.js'; +import { comparePropertiesAlphabetically } from './alphabetical-property-comparator.js'; + +/** + * Processes a font face declaration to enforce property ordering: + * 'src' first, then other properties alphabetically. + * + * @param ruleContext The ESLint rule context for reporting and fixing issues. + * @param fontFaceObject The object expression representing the font face declaration. + */ +export const enforceFontFaceOrder = ( + ruleContext: Rule.RuleContext, + fontFaceObject: TSESTree.ObjectExpression, +): void => { + if (!fontFaceObject || fontFaceObject.type !== AST_NODE_TYPES.ObjectExpression) { + return; + } + + const { regularProperties } = separateProperties(fontFaceObject.properties); + + if (regularProperties.length <= 1) { + return; + } + + // Create pairs of consecutive properties + const propertyPairs = regularProperties.slice(0, -1).map((currentProperty, index) => ({ + currentProperty, + nextProperty: regularProperties[index + 1] as TSESTree.Property, + })); + + const violatingPair = propertyPairs.find( + ({ currentProperty, nextProperty }) => comparePropertiesAlphabetically(currentProperty, nextProperty) > 0, + ); + + if (violatingPair) { + const nextPropertyName = getPropertyName(violatingPair.nextProperty); + const currentPropertyName = getPropertyName(violatingPair.currentProperty); + + ruleContext.report({ + node: violatingPair.nextProperty as Rule.Node, + messageId: 'fontFaceOrder', + data: { + nextProperty: nextPropertyName, + currentProperty: currentPropertyName, + }, + fix: (fixer) => + generateFixesForCSSOrder( + fixer, + ruleContext, + regularProperties, + comparePropertiesAlphabetically, + (property) => property as Rule.Node, + ), + }); + } +}; 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 0608cbb..19d8bae 100644 --- a/src/css-rules/shared-utils/order-strategy-visitor-creator.ts +++ b/src/css-rules/shared-utils/order-strategy-visitor-creator.ts @@ -6,6 +6,7 @@ import { enforceConcentricCSSOrderInRecipe } from '../concentric-order/recipe-or import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/style-object-processor.js'; import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js'; import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.js'; +import { enforceFontFaceOrder } from './font-face-property-order-enforcer.js'; import { processStyleNode } from './style-node-processor.js'; /** @@ -58,6 +59,26 @@ export const createNodeVisitors = ( return; } + const fontFaceFunctionArgumentIndexMap = { + fontFace: 0, // First argument (index 0) + globalFontFace: 1, // Second argument (index 1) + }; + + // Handle font face functions with special ordering + if ( + node.callee.name in fontFaceFunctionArgumentIndexMap && + node.arguments.length > + fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap] + ) { + const argumentIndex = + fontFaceFunctionArgumentIndexMap[node.callee.name as keyof typeof fontFaceFunctionArgumentIndexMap]; + const styleArguments = node.arguments[argumentIndex]; + + enforceFontFaceOrder(ruleContext, styleArguments as TSESTree.ObjectExpression); + + return; + } + // Handle style-related functions if ( ['createThemeContract', 'createVar', 'createTheme', 'keyframes', 'style', 'styleVariants'].includes( @@ -65,8 +86,8 @@ export const createNodeVisitors = ( ) ) { if (node.arguments.length > 0) { - const styleArg = node.arguments[0]; - processStyleNode(ruleContext, styleArg as TSESTree.Node, processProperty); + const styleArguments = node.arguments[0]; + processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty); } } @@ -75,8 +96,8 @@ export const createNodeVisitors = ( (node.callee.name === 'globalKeyframes' || node.callee.name === 'globalStyle') && node.arguments.length >= 2 ) { - const styleArg = node.arguments[1]; - processStyleNode(ruleContext, styleArg as TSESTree.Node, processProperty); + const styleArguments = node.arguments[1]; + processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty); } // Handle recipe function diff --git a/src/css-sample/sample.css.ts b/src/css-sample/sample.css.ts index 4c4f707..3368c8d 100644 --- a/src/css-sample/sample.css.ts +++ b/src/css-sample/sample.css.ts @@ -13,37 +13,37 @@ import { recipe } from '@vanilla-extract/recipes'; 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")'], - fontWeight: '400 700', - fontStyle: 'normal', - fontStretch: 'normal', - fontDisplay: 'swap', - 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', 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%', - fontVariant: 'normal', - fontFeatureSettings: '"liga" 1', - fontVariationSettings: '"wght" 400', + 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")'], - fontWeight: '400 700', - fontStyle: 'normal', - fontStretch: 'normal', - fontDisplay: 'swap', - 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', 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%', - fontVariant: 'normal', - fontFeatureSettings: '"liga" 1', - fontVariationSettings: '"wght" 400', + 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 diff --git a/src/index.ts b/src/index.ts index ab78691..1982048 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import customOrderRule from './css-rules/custom-order/rule-definition.js'; export const vanillaExtract = { meta: { name: '@antebudimir/eslint-plugin-vanilla-extract', - version: '1.3.1', + version: '1.4.0', }, rules: { 'alphabetical-order': alphabeticalOrderRule,