Repository: microsoft/griffel
Pull request #430 (2023-09-01 10:01:40)

feat(eslint-plugin): Adds WIP rule for css linting
Adds new rule for css linting. No linting happens in this PR, but all steps until CSS evaluation is done in the rule.

The rule is not exported because it is WIP.

github-actions[bot]: ## 📊 Bundle size report

<sub>🤖 This report was generated against <a href='https://github.com/microsoft/griffel/commit/64613e2b35984a40f1e1de4229409669d3499bfb'>64613e2b35984a40f1e1de4229409669d3499bfb</a></sub>

<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/change/@griffel-eslint-plugin-5d860b0c-ef67-4015-a825-eae4b10d1636.json b/change/@griffel-eslint-plugin-5d860b0c-ef67-4015-a825-eae4b10d1636.json
new file mode 100644
index 0000000..66787c4
--- /dev/null
+++ b/change/@griffel-eslint-plugin-5d860b0c-ef67-4015-a825-eae4b10d1636.json
@@ -0,0 +1,7 @@
+{
+  "type": "none",
+  "comment": "feat(eslint-plugin): Adds WIP rule for css linting",
+  "packageName": "@griffel/eslint-plugin",
+  "email": "lingfan.gao@microsoft.com",
+  "dependentChangeType": "none"
+}
diff --git a/change/@griffel-style-types-ce9ebd17-8bff-4a8f-977b-21c6d5621021.json b/change/@griffel-style-types-ce9ebd17-8bff-4a8f-977b-21c6d5621021.json
new file mode 100644
index 0000000..4a3cc89
--- /dev/null
+++ b/change/@griffel-style-types-ce9ebd17-8bff-4a8f-977b-21c6d5621021.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "fix: allow CSS variables in animationName, stricter types for selectors",
+  "packageName": "@griffel/style-types",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/change/@griffel-webpack-extraction-plugin-bba54fd0-77a2-4f33-862c-4f32d6656129.json b/change/@griffel-webpack-extraction-plugin-bba54fd0-77a2-4f33-862c-4f32d6656129.json
deleted file mode 100644
index a882a14..0000000
--- a/change/@griffel-webpack-extraction-plugin-bba54fd0-77a2-4f33-862c-4f32d6656129.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "type": "none",
-  "comment": "chore: refactor tests for @griffel/webpack-extraction-plugin",
-  "packageName": "@griffel/webpack-extraction-plugin",
-  "email": "olfedias@microsoft.com",
-  "dependentChangeType": "none"
-}
diff --git a/change/@griffel-webpack-extraction-plugin-f0a3ecf3-b091-4925-89ba-f47328169994.json b/change/@griffel-webpack-extraction-plugin-f0a3ecf3-b091-4925-89ba-f47328169994.json
deleted file mode 100644
index 3f24620..0000000
--- a/change/@griffel-webpack-extraction-plugin-f0a3ecf3-b091-4925-89ba-f47328169994.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "type": "patch",
-  "comment": "chore: remove experimental_resetModuleIndexes",
-  "packageName": "@griffel/webpack-extraction-plugin",
-  "email": "olfedias@microsoft.com",
-  "dependentChangeType": "patch"
-}
diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json
index cc729fc..409839f 100644
--- a/packages/eslint-plugin/package.json
+++ b/packages/eslint-plugin/package.json
@@ -8,8 +8,10 @@
     "url": "https://github.com/microsoft/griffel"
   },
   "dependencies": {
-    "csstype": "^3.1.2",
+    "@griffel/babel-preset": "^1.5.0",
+    "@babel/core": "^7.12.13",
     "@typescript-eslint/utils": "^5.47.0",
+    "csstype": "^3.1.2",
     "tslib": "^2.1.0"
   },
   "peerDependencies": {
diff --git a/packages/eslint-plugin/src/rules/css-lint/css-lint.md b/packages/eslint-plugin/src/rules/css-lint/css-lint.md
new file mode 100644
index 0000000..6d3f665
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/css-lint/css-lint.md
@@ -0,0 +1,1 @@
+WIP
\ No newline at end of file
diff --git a/packages/eslint-plugin/src/rules/css-lint/css-lint.ts b/packages/eslint-plugin/src/rules/css-lint/css-lint.ts
new file mode 100644
index 0000000..a8938f1
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/css-lint/css-lint.ts
@@ -0,0 +1,166 @@
+import { ASTUtils, TSESTree } from '@typescript-eslint/utils';
+import { transformSync } from './transform-sync';
+import { BabelPluginOptions } from '@griffel/babel-preset';
+
+import {
+  isIdentifier,
+  isMakeStylesIdentifier,
+  isObjectExpression,
+  isProperty,
+  isMakeResetStylesIdentifier,
+} from '../../utils/helpers';
+import { createRule } from '../../utils/createRule';
+
+type Options = [BabelPluginOptions];
+
+type MessageIds = 'foo';
+
+// TODO createRule does not generate docs url properly for sub-directoryjќ
+export default createRule<Options, MessageIds>({
+  name: 'css-lint',
+  meta: {
+    type: 'problem',
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          generateMetadata: {
+            type: 'boolean',
+          },
+          modules: {
+            description: 'Configures the import name and source of Griffel',
+            type: 'array',
+            items: {
+              type: 'object',
+              required: ['moduleSource', 'importName'],
+              properties: {
+                moduleSource: {
+                  type: 'string',
+                },
+                importName: {
+                  type: 'string',
+                },
+              },
+            },
+          },
+          babelOptions: {
+            description:
+              'Configures the Griffel babel preset, see https://github.com/microsoft/griffel/blob/main/packages/babel-preset/README.md',
+            type: 'object',
+            properties: {
+              plugins: {
+                type: 'array',
+              },
+              presets: {
+                type: 'array',
+              },
+            },
+          },
+          evaluationRules: {
+            description:
+              'Configures the Griffel babel preset, see https://github.com/microsoft/griffel/blob/main/packages/babel-preset/README.md',
+            type: 'array',
+            items: {
+              type: 'object',
+              required: ['action'],
+              properties: {},
+            },
+          },
+          projectRoot: {
+            type: 'string',
+          },
+        },
+      },
+    ],
+    messages: {
+      foo: 'WIP - not ready yet',
+    },
+    docs: {
+      description: 'WIP - not ready yet',
+      recommended: 'error',
+    },
+  },
+  defaultOptions: [{}],
+  create(context) {
+    // Map of all style slots to the makeStyles return function name
+    const slotPropertiesByDeclaratorId = new Map<string, Map<string, TSESTree.Property>>();
+    // map of all styles to the return of the makeResetStyles function name
+    const resetEntriesByDeclaratorId = new Map<string, TSESTree.CallExpression>();
+    const options = context.options[0];
+
+    return {
+      'Program:exit'(node) {
+        if (!context.getPhysicalFilename) {
+          // TODO add something actionable here
+          throw new Error('No physical filename could be found');
+        }
+
+        const filename = context.getPhysicalFilename();
+        const source = context.getSourceCode().getText();
+        const { metadata } = transformSync(source, {
+          filename,
+          pluginOptions: {
+            ...options,
+            // This rule cannot do anything without metadata - always generate them
+            generateMetadata: true,
+          },
+        });
+
+        const { cssEntries, cssResetEntries } = metadata;
+
+        for (const [declaratorId, cssRulesBySlot] of Object.entries(cssEntries)) {
+          for (const [slotName /** rules */] of Object.entries(cssRulesBySlot)) {
+            // TODO add actual css linting
+            const errors: object[] = [{}];
+            if (Object.keys(errors).length) {
+              const property = slotPropertiesByDeclaratorId.get(declaratorId)?.get(slotName);
+              context.report({ messageId: 'foo', node: property ?? node });
+            }
+          }
+        }
+
+        for (const resetDeclarator in cssResetEntries) {
+          const errors: object[] = [{}];
+          // TODO add actual css linting
+          if (Object.keys(errors).length) {
+            const property = resetEntriesByDeclaratorId.get(resetDeclarator);
+            context.report({ messageId: 'foo', node: property ?? node });
+          }
+        }
+      },
+      CallExpression(node) {
+        const variableDeclarator = node.parent;
+        if (!ASTUtils.isVariableDeclarator(variableDeclarator)) {
+          return;
+        }
+
+        let declaratorId = '';
+        if (isIdentifier(variableDeclarator.id)) {
+          declaratorId = variableDeclarator.id.name;
+        }
+
+        if (isMakeResetStylesIdentifier(node.callee)) {
+          resetEntriesByDeclaratorId.set(declaratorId, node);
+        }
+
+        if (isMakeStylesIdentifier(node.callee)) {
+          const slotProperties = new Map<string, TSESTree.Property>();
+          const slots = node.arguments[0];
+          if (isObjectExpression(slots)) {
+            slots.properties.forEach(slot => {
+              if (
+                isProperty(slot) &&
+                isIdentifier(slot.key) &&
+                (isObjectExpression(slot.value) || isIdentifier(slot.value))
+              ) {
+                slotProperties.set(slot.key.name, slot);
+              }
+            });
+          }
+
+          slotPropertiesByDeclaratorId.set(declaratorId, slotProperties);
+        }
+      },
+    };
+  },
+});
diff --git a/packages/eslint-plugin/src/rules/css-lint/transform-sync.ts b/packages/eslint-plugin/src/rules/css-lint/transform-sync.ts
new file mode 100644
index 0000000..05e527c
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/css-lint/transform-sync.ts
@@ -0,0 +1,50 @@
+import * as Babel from '@babel/core';
+import griffelPreset, { BabelPluginMetadata, BabelPluginOptions } from '@griffel/babel-preset';
+
+export type TransformOptions = {
+  filename: string;
+  pluginOptions: BabelPluginOptions;
+};
+
+export type TransformResult = {
+  code: string;
+  metadata: BabelPluginMetadata;
+};
+
+/**
+ * Transforms passed source code with Babel, uses user's config for parsing, but ignores it for transforms.
+ */
+export function transformSync(sourceCode: string, options: TransformOptions): TransformResult {
+  // Parse the code first so Babel will use user's babel config for parsing
+  // During transforms we don't want to use user's config
+  const babelAST = Babel.parseSync(sourceCode, {
+    caller: { name: 'griffel' },
+    filename: options.filename,
+    // TODO - we might need to add some babel config in the eslint rule options
+    // if the user doesn't want to use their normal babel config for this
+    // but let's only add it if it becomes necessary
+  });
+
+  if (babelAST === null) {
+    throw new Error(`Failed to create AST for "${options.filename}" due unknown Babel error...`);
+  }
+
+  const babelFileResult = Babel.transformFromAstSync(babelAST, sourceCode, {
+    // Ignore all user's configs and apply only our plugin
+    babelrc: false,
+    configFile: false,
+    presets: [[griffelPreset, options.pluginOptions]],
+
+    filename: options.filename,
+    sourceFileName: options.filename,
+  });
+
+  if (babelFileResult === null) {
+    throw new Error(`Failed to transform "${options.filename}" due unknown Babel error...`);
+  }
+
+  return {
+    metadata: babelFileResult.metadata as unknown as BabelPluginMetadata,
+    code: babelFileResult.code as string,
+  };
+}
diff --git a/packages/eslint-plugin/src/utils/helpers.ts b/packages/eslint-plugin/src/utils/helpers.ts
index d472a65..272deda 100644
--- a/packages/eslint-plugin/src/utils/helpers.ts
+++ b/packages/eslint-plugin/src/utils/helpers.ts
@@ -14,6 +14,11 @@ export const isProperty: IsHelper<AST_NODE_TYPES.Property> = ASTUtils.isNodeOfTy
 export function isMakeStylesIdentifier(node: TSESTree.Node | null | undefined): node is TSESTree.Identifier {
   return isIdentifier(node) && node.name === 'makeStyles';
 }
+
+export function isMakeResetStylesIdentifier(node: TSESTree.Node | null | undefined): node is TSESTree.Identifier {
+  return isIdentifier(node) && node.name === 'makeResetStyles';
+}
+
 export function isStringLiteral(node: TSESTree.Node | null | undefined): node is TSESTree.StringLiteral {
   return isLiteral(node) && typeof node.value === 'string';
 }
diff --git a/packages/next-extraction-plugin/CHANGELOG.json b/packages/next-extraction-plugin/CHANGELOG.json
index b995959..85643a4 100644
--- a/packages/next-extraction-plugin/CHANGELOG.json
+++ b/packages/next-extraction-plugin/CHANGELOG.json
@@ -1,6 +1,21 @@
 {
   "name": "@griffel/next-extraction-plugin",
   "entries": [
+    {
+      "date": "Fri, 01 Sep 2023 12:27:27 GMT",
+      "tag": "@griffel/next-extraction-plugin_v0.0.29",
+      "version": "0.0.29",
+      "comments": {
+        "patch": [
+          {
+            "author": "beachball",
+            "package": "@griffel/next-extraction-plugin",
+            "comment": "Bump @griffel/webpack-extraction-plugin to v0.3.20",
+            "commit": "64613e2b35984a40f1e1de4229409669d3499bfb"
+          }
+        ]
+      }
+    },
     {
       "date": "Mon, 28 Aug 2023 17:17:47 GMT",
       "tag": "@griffel/next-extraction-plugin_v0.0.28",
diff --git a/packages/next-extraction-plugin/CHANGELOG.md b/packages/next-extraction-plugin/CHANGELOG.md
index 9c46aca..4180aa0 100644
--- a/packages/next-extraction-plugin/CHANGELOG.md
+++ b/packages/next-extraction-plugin/CHANGELOG.md
@@ -1,9 +1,17 @@
 # Change Log - @griffel/next-extraction-plugin
 
-This log was last generated on Mon, 28 Aug 2023 17:17:47 GMT and should not be manually modified.
+This log was last generated on Fri, 01 Sep 2023 12:27:27 GMT and should not be manually modified.
 
 <!-- Start content -->
 
+## 0.0.29
+
+Fri, 01 Sep 2023 12:27:27 GMT
+
+### Patches
+
+- Bump @griffel/webpack-extraction-plugin to v0.3.20
+
 ## 0.0.28
 
 Mon, 28 Aug 2023 17:17:47 GMT
diff --git a/packages/next-extraction-plugin/package.json b/packages/next-extraction-plugin/package.json
index f3e8c92..8c33b31 100644
--- a/packages/next-extraction-plugin/package.json
+++ b/packages/next-extraction-plugin/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@griffel/next-extraction-plugin",
-  "version": "0.0.28",
+  "version": "0.0.29",
   "description": "NextJS plugin that add Griffel's CSS extraction to webpack loaders pipeline",
   "license": "MIT",
   "repository": {
@@ -8,7 +8,7 @@
     "url": "https://github.com/microsoft/griffel"
   },
   "dependencies": {
-    "@griffel/webpack-extraction-plugin": "^0.3.19",
+    "@griffel/webpack-extraction-plugin": "^0.3.20",
     "browserslist": "^4.19.1",
     "tslib": "^2.1.0"
   },
diff --git a/packages/style-types/package.json b/packages/style-types/package.json
index 17c4020..fee49e0 100644
--- a/packages/style-types/package.json
+++ b/packages/style-types/package.json
@@ -12,7 +12,7 @@
     "csstype": "^3.1.2"
   },
   "typesVersions": {
-    "<4.1": {
+    "<4.4": {
       "src/index.d.ts": [
         "src/legacy/index.d.ts"
       ]
diff --git a/packages/style-types/src/makeResetStyles.ts b/packages/style-types/src/makeResetStyles.ts
index 9261d7a..39cccfc 100644
--- a/packages/style-types/src/makeResetStyles.ts
+++ b/packages/style-types/src/makeResetStyles.ts
@@ -23,5 +23,8 @@ type GriffelCSSPseudos = {
   [Property in CSS.Pseudos]?: GriffelResetStylesStrictCSSObject | GriffelCSSObjectCustom;
 };
 
-export type GriffelResetAnimation = Record<'from' | 'to' | string, GriffelResetStylesCSSProperties>;
+export type GriffelResetAnimation = Record<
+  'from' | 'to' | string,
+  GriffelResetStylesCSSProperties & { [Property in `--${string}`]: string }
+>;
 export type GriffelResetStyle = GriffelResetStylesStrictCSSObject | GriffelCSSObjectCustom;
diff --git a/packages/style-types/src/makeStyles.ts b/packages/style-types/src/makeStyles.ts
index c96802e..d29eff2 100644
--- a/packages/style-types/src/makeStyles.ts
+++ b/packages/style-types/src/makeStyles.ts
@@ -27,5 +27,8 @@ type GriffelCSSPseudos = {
   [Property in CSS.Pseudos]?: GriffelStylesStrictCSSObject | GriffelCSSObjectCustom;
 };
 
-export type GriffelAnimation = Record<'from' | 'to' | string, GriffelStylesCSSProperties>;
+export type GriffelAnimation = Record<
+  'from' | 'to' | string,
+  GriffelStylesCSSProperties & { [Property in `--${string}`]: string }
+>;
 export type GriffelStyle = GriffelStylesStrictCSSObject | GriffelCSSObjectCustom;
diff --git a/packages/webpack-extraction-plugin/CHANGELOG.json b/packages/webpack-extraction-plugin/CHANGELOG.json
index ababd73..e94d840 100644
--- a/packages/webpack-extraction-plugin/CHANGELOG.json
+++ b/packages/webpack-extraction-plugin/CHANGELOG.json
@@ -1,6 +1,29 @@
 {
   "name": "@griffel/webpack-extraction-plugin",
   "entries": [
+    {
+      "date": "Fri, 01 Sep 2023 12:27:27 GMT",
+      "tag": "@griffel/webpack-extraction-plugin_v0.3.20",
+      "version": "0.3.20",
+      "comments": {
+        "none": [
+          {
+            "author": "olfedias@microsoft.com",
+            "package": "@griffel/webpack-extraction-plugin",
+            "commit": "64613e2b35984a40f1e1de4229409669d3499bfb",
+            "comment": "chore: refactor tests for @griffel/webpack-extraction-plugin"
+          }
+        ],
+        "patch": [
+          {
+            "author": "olfedias@microsoft.com",
+            "package": "@griffel/webpack-extraction-plugin",
+            "commit": "6780f10dba48187e5699cdf2ff2884e651fc58dc",
+            "comment": "chore: remove experimental_resetModuleIndexes"
+          }
+        ]
+      }
+    },
     {
       "date": "Mon, 28 Aug 2023 17:17:47 GMT",
       "tag": "@griffel/webpack-extraction-plugin_v0.3.19",
diff --git a/packages/webpack-extraction-plugin/CHANGELOG.md b/packages/webpack-extraction-plugin/CHANGELOG.md
index 1d1750f..8613325 100644
--- a/packages/webpack-extraction-plugin/CHANGELOG.md
+++ b/packages/webpack-extraction-plugin/CHANGELOG.md
@@ -1,9 +1,17 @@
 # Change Log - @griffel/webpack-extraction-plugin
 
-This log was last generated on Mon, 28 Aug 2023 17:17:47 GMT and should not be manually modified.
+This log was last generated on Fri, 01 Sep 2023 12:27:27 GMT and should not be manually modified.
 
 <!-- Start content -->
 
+## 0.3.20
+
+Fri, 01 Sep 2023 12:27:27 GMT
+
+### Patches
+
+- chore: remove experimental_resetModuleIndexes (olfedias@microsoft.com)
+
 ## 0.3.19
 
 Mon, 28 Aug 2023 17:17:47 GMT
diff --git a/packages/webpack-extraction-plugin/package.json b/packages/webpack-extraction-plugin/package.json
index 5755425..c319a77 100644
--- a/packages/webpack-extraction-plugin/package.json
+++ b/packages/webpack-extraction-plugin/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@griffel/webpack-extraction-plugin",
-  "version": "0.3.19",
+  "version": "0.3.20",
   "description": "Webpack plugin that performs CSS extraction for Griffel",
   "license": "MIT",
   "repository": {


Test patch:
diff --git a/e2e/typescript/src/assets/fixture-reset.ts b/e2e/typescript/src/assets/fixture-reset.ts
index 73c5921..1a03132 100644
--- a/e2e/typescript/src/assets/fixture-reset.ts
+++ b/e2e/typescript/src/assets/fixture-reset.ts
@@ -24,6 +24,12 @@ assertType({
     },
   ],
 });
+assertType({
+  animationName: {
+    from: { '--opacity': '0' },
+    to: { '--opacity': '1' },
+  },
+});
 
 assertType({
   // @ts-expect-error "200" is not a valid CSS value for "height"
@@ -31,6 +37,12 @@ assertType({
     to: { height: 200 },
   },
 });
+assertType({
+  // @ts-expect-error Only strings can be used as values for CSS variables
+  animationName: {
+    from: { '--opacity': 0 },
+  },
+});
 
 // Basic styles
 //
@@ -97,6 +109,11 @@ assertType({
   ':hover': { animationName: { from: {}, to: {} } },
 });
 
+assertType({
+  // @ts-expect-error Values of selectors can be only objects
+  ':hover': 'red',
+});
+
 // Custom selectors
 //
 
diff --git a/e2e/typescript/src/assets/fixture.ts b/e2e/typescript/src/assets/fixture.ts
index bf60a9b..9d8dba6 100644
--- a/e2e/typescript/src/assets/fixture.ts
+++ b/e2e/typescript/src/assets/fixture.ts
@@ -24,6 +24,12 @@ assertType({
     },
   ],
 });
+assertType({
+  animationName: {
+    from: { '--opacity': '0' },
+    to: { '--opacity': '1' },
+  },
+});
 
 assertType({
   // @ts-expect-error "200" is not a valid CSS value for "height"
@@ -31,6 +37,12 @@ assertType({
     to: { height: 200 },
   },
 });
+assertType({
+  // @ts-expect-error Only strings can be used as values for CSS variables
+  animationName: {
+    from: { '--opacity': 0 },
+  },
+});
 
 // Basic styles
 //
@@ -94,6 +106,11 @@ assertType({
   ':hover': { animationName: { from: {}, to: {} } },
 });
 
+assertType({
+  // @ts-expect-error Values of selectors can be only objects
+  ':hover': 'red',
+});
+
 // Custom selectors
 //
 
diff --git a/e2e/typescript/src/test.ts b/e2e/typescript/src/test.ts
index b6e9d8d..972a0cd 100644
--- a/e2e/typescript/src/test.ts
+++ b/e2e/typescript/src/test.ts
@@ -76,7 +76,8 @@ async function performTest(tsVersion: string, options: { mode?: 'legacy' | 'mode
 
 (async () => {
   await performTest('3.9', { mode: 'legacy' });
-  await performTest('4.1');
+  await performTest('4.1', { mode: 'legacy' });
+  await performTest('4.3', { mode: 'legacy' });
   await performTest('4.4');
   await performTest('4.9');
   await performTest('5.0');
diff --git a/packages/eslint-plugin/src/rules/css-lint/css-lint.test.ts b/packages/eslint-plugin/src/rules/css-lint/css-lint.test.ts
new file mode 100644
index 0000000..9623ce8
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/css-lint/css-lint.test.ts
@@ -0,0 +1,32 @@
+import * as path from 'path';
+import { TSESLint } from '@typescript-eslint/utils';
+import rule from './css-lint';
+
+const ruleTesterInstance = new TSESLint.RuleTester({
+  parser: path.resolve('./node_modules/@typescript-eslint/parser'),
+  parserOptions: {
+    ecmaVersion: 2018,
+    sourceType: 'module',
+  },
+});
+ruleTesterInstance.run('css-lint', rule, {
+  valid: [],
+  invalid: [
+    {
+      name: 'test',
+      errors: [{ messageId: 'foo' }],
+      code: `
+      import { makeStyles } from '@griffel/react'
+      const useStyles = makeStyles({
+        root: {
+          '& div': {
+            color: 'red',
+            backgroundColor: 'blue',
+          }
+        }
+      })
+      `,
+      filename: './useComponentStyles.styles.ts',
+    },
+  ],
+});