feat 🥁: implement special ordering for fontFace APIs

- Ensure 'src' property always appears first
- Sort remaining properties alphabetically
- Handle both APIs correctly despite different argument structures
- Handles font faces ordering the same in all 3 available CSS rules
- Update documentation with fontFace ordering details
This commit is contained in:
Ante Budimir 2025-03-08 23:05:23 +02:00
parent 8916be7d16
commit 3e9bad1b02
12 changed files with 175 additions and 47 deletions

View file

@ -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 ## Concentric CSS Model
Here's a list of all available groups from the provided [concentricGroups](src/css-rules/concentric-order/concentric-groups.ts) array: 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. - Initial release with support for alphabetical, concentric, and custom group CSS ordering.
- Auto-fix capability integrated into ESLint. - 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 ### Current Work
- `fontFace` and `globalFontFace` linting support. - Test coverage.
### Upcoming Features ### Upcoming Features
- Test coverage.
- `no-empty-blocks` rule to disallow empty blocks. - `no-empty-blocks` rule to disallow empty blocks.
- `no-unknown-units` rule to disallow unknown units. - `no-unknown-units` rule to disallow unknown units.
- `no-number-trailing-zeros` rule to disallow trailing zeros in numbers. - `no-number-trailing-zeros` rule to disallow trailing zeros in numbers.
- `no-zero-unit` rule to disallow units when the value is zero. - `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-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. - `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. - 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 ## Contributing

View file

@ -1,6 +1,6 @@
{ {
"name": "@antebudimir/eslint-plugin-vanilla-extract", "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", "description": "ESLint plugin for enforcing CSS ordering in vanilla-extract styles",
"author": "Ante Budimir", "author": "Ante Budimir",
"license": "MIT", "license": "MIT",

View file

@ -1,23 +1,9 @@
import type { Rule } from 'eslint'; import type { Rule } from 'eslint';
import { TSESTree } from '@typescript-eslint/utils'; 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 { generateFixesForCSSOrder } from '../shared-utils/css-order-fixer.js';
import { getPropertyName } from '../shared-utils/property-separator.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. * Reports an ordering issue to ESLint and generates fixes.
* @param ruleContext The ESLint rule context. * @param ruleContext The ESLint rule context.

View file

@ -12,7 +12,9 @@ const alphabeticalOrderRule: Rule.RuleModule = {
fixable: 'code', fixable: 'code',
schema: [], schema: [],
messages: { 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) { create(context) {

View file

@ -20,8 +20,8 @@ const reportOrderingIssue = (
node: nextProperty.node as Rule.Node, node: nextProperty.node as Rule.Node,
messageId: 'incorrectOrder', messageId: 'incorrectOrder',
data: { data: {
next: nextProperty.name, nextProperty: nextProperty.name,
current: currentProperty.name, currentProperty: currentProperty.name,
}, },
fix: (fixer) => fix: (fixer) =>
generateFixesForCSSOrder( generateFixesForCSSOrder(

View file

@ -8,11 +8,15 @@ const concentricOrderRule: Rule.RuleModule = {
description: 'enforce concentric CSS property ordering in vanilla-extract styles', description: 'enforce concentric CSS property ordering in vanilla-extract styles',
category: 'Stylistic Issues', category: 'Stylistic Issues',
recommended: true, recommended: true,
url: 'https://rhodesmill.org/brandon/2011/concentric-css/',
}, },
fixable: 'code', fixable: 'code',
schema: [], schema: [],
messages: { 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) { create(context) {

View file

@ -36,6 +36,8 @@ const customGroupOrderRule: Rule.RuleModule = {
messages: { messages: {
incorrectOrder: incorrectOrder:
"Property '{{nextProperty}}' should come before '{{currentProperty}}' according to custom CSS group ordering.", "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) { create(ruleContext: Rule.RuleContext) {

View file

@ -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);
};

View file

@ -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,
),
});
}
};

View file

@ -6,6 +6,7 @@ import { enforceConcentricCSSOrderInRecipe } from '../concentric-order/recipe-or
import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/style-object-processor.js'; import { enforceConcentricCSSOrderInStyleObject } from '../concentric-order/style-object-processor.js';
import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js'; import { enforceUserDefinedGroupOrderInRecipe } from '../custom-order/recipe-order-enforcer.js';
import { enforceUserDefinedGroupOrderInStyleObject } from '../custom-order/style-object-processor.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'; import { processStyleNode } from './style-node-processor.js';
/** /**
@ -58,6 +59,26 @@ export const createNodeVisitors = (
return; 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 // Handle style-related functions
if ( if (
['createThemeContract', 'createVar', 'createTheme', 'keyframes', 'style', 'styleVariants'].includes( ['createThemeContract', 'createVar', 'createTheme', 'keyframes', 'style', 'styleVariants'].includes(
@ -65,8 +86,8 @@ export const createNodeVisitors = (
) )
) { ) {
if (node.arguments.length > 0) { if (node.arguments.length > 0) {
const styleArg = node.arguments[0]; const styleArguments = node.arguments[0];
processStyleNode(ruleContext, styleArg as TSESTree.Node, processProperty); processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
} }
} }
@ -75,8 +96,8 @@ export const createNodeVisitors = (
(node.callee.name === 'globalKeyframes' || node.callee.name === 'globalStyle') && (node.callee.name === 'globalKeyframes' || node.callee.name === 'globalStyle') &&
node.arguments.length >= 2 node.arguments.length >= 2
) { ) {
const styleArg = node.arguments[1]; const styleArguments = node.arguments[1];
processStyleNode(ruleContext, styleArg as TSESTree.Node, processProperty); processStyleNode(ruleContext, styleArguments as TSESTree.Node, processProperty);
} }
// Handle recipe function // Handle recipe function

View file

@ -13,37 +13,37 @@ import { recipe } from '@vanilla-extract/recipes';
export const theFont = fontFace({ export const theFont = fontFace({
// Comment to test that the linter doesn't remove it // Comment to test that the linter doesn't remove it
src: ['url("/fonts/MyFont.woff2") format("woff2")', 'url("/fonts/MyFont.woff") format("woff")'], 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%', ascentOverride: '90%',
descentOverride: '10%', descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontVariationSettings: '"wght" 400',
fontWeight: '400 700',
lineGapOverride: '10%', lineGapOverride: '10%',
sizeAdjust: '90%', sizeAdjust: '90%',
fontVariant: 'normal', unicodeRange:
fontFeatureSettings: '"liga" 1', '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',
fontVariationSettings: '"wght" 400',
}); });
globalFontFace('GlobalFont', { globalFontFace('GlobalFont', {
// Comment to test that the linter doesn't remove it // Comment to test that the linter doesn't remove it
src: ['url("/fonts/MyFont.woff2") format("woff2")', 'url("/fonts/MyFont.woff") format("woff")'], 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%', ascentOverride: '90%',
descentOverride: '10%', descentOverride: '10%',
fontDisplay: 'swap',
fontFeatureSettings: '"liga" 1',
fontStretch: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
fontVariationSettings: '"wght" 400',
fontWeight: '400 700',
lineGapOverride: '10%', lineGapOverride: '10%',
sizeAdjust: '90%', sizeAdjust: '90%',
fontVariant: 'normal', unicodeRange:
fontFeatureSettings: '"liga" 1', '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',
fontVariationSettings: '"wght" 400',
}); });
// keyframes // keyframes

View file

@ -5,7 +5,7 @@ import customOrderRule from './css-rules/custom-order/rule-definition.js';
export const vanillaExtract = { export const vanillaExtract = {
meta: { meta: {
name: '@antebudimir/eslint-plugin-vanilla-extract', name: '@antebudimir/eslint-plugin-vanilla-extract',
version: '1.3.1', version: '1.4.0',
}, },
rules: { rules: {
'alphabetical-order': alphabeticalOrderRule, 'alphabetical-order': alphabeticalOrderRule,