Repository: microsoft/griffel
Pull request #425 (2023-08-22 13:54:32)

feat: Add metadata generation to babel preset
Adds a new plugin option `generateMetadata`.

Once this option is set, the babel preset will return metadata on the evaluated file containing Griffel styles in the below schema:

```ts
{
  // makeStyles
  cssEntries: {
    // by each hook
    useStyles1: {
      // by each slot
      root:  [ .fxxxxx { color: 'red' } ],
    }
    useStyles2: {
      root:  [ .fxxxxx { color: 'red' } ],
    },
  },

  // makeResetStyles
  cssResetEntries: {
    // by each hook
    useResetStyles1: [ .fxxxxx { color: 'red' } ],
    useResetStyles2: [ .fxxxxx { color: 'red' } ],
  }
}
```

This metadata is intended for programmatic transforms in code that can be reused by other tooling:


```ts
  const babelFileResult = Babel.transformFromAstSync(babelAST, sourceCode, {
    babelrc: false,
    configFile: false,
    presets: [[griffelPreset, { generateMetadata: true }]],

    filename: options.filename,

    sourceMaps: options.enableSourceMaps,
    sourceFileName: options.filename,
    inputSourceMap: options.inputSourceMap,
  });

  // metadata
  console.log(babelFileResult.metadata);
```

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

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

<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/change/@griffel-babel-preset-84c90e38-9d5d-40cf-9ef9-ef07ef7886b5.json b/change/@griffel-babel-preset-84c90e38-9d5d-40cf-9ef9-ef07ef7886b5.json
new file mode 100644
index 0000000..f11a6a3
--- /dev/null
+++ b/change/@griffel-babel-preset-84c90e38-9d5d-40cf-9ef9-ef07ef7886b5.json
@@ -0,0 +1,7 @@
+{
+  "type": "minor",
+  "comment": "feat: Add metadata generation to babel preset",
+  "packageName": "@griffel/babel-preset",
+  "email": "lingfan.gao@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/babel-preset/README.md b/packages/babel-preset/README.md
index 46c8423..aa56352 100644
--- a/packages/babel-preset/README.md
+++ b/packages/babel-preset/README.md
@@ -13,6 +13,7 @@ A Babel preset that performs build time transforms for [`@griffel/react`](../rea
   - [Configuring module evaluation](#configuring-module-evaluation)
 - [Transforms](#transforms)
 - [Example](#example)
+- [Access CSS output from code](#access-css-output-from-code)
 - [Troubleshooting](#troubleshooting)
   - [Module evaluation](#module-evaluation)
 
@@ -161,6 +162,55 @@ import { __styles } from '@griffel/react';
 const useStyles = __styles(/* resolved styles */);
 ```
 
+## Access CSS output from code
+
+It's possible to configure the preset to return all the evaluated styles of a file. This
+metadata looks something like below:
+
+```ts
+const output = {
+  // makeStyles
+  cssEntries: {
+    // by each hook
+    useStyles1: {
+      // by each slot
+      root: [".fxxxxx { color: 'red' }"],
+    },
+    useStyles2: {
+      root: [".fxxxxx { color: 'red' }"],
+    },
+  },
+  // makeResetStyles
+  cssResetEntries: {
+    // by each hook
+    useResetStyles1: [".fxxxxx { color: 'red' }"],
+    useResetStyles2: [".fxxxxx { color: 'red' }"],
+  },
+};
+```
+
+This is intended for programmatic transforms in code so that they can be reused by other tooling without extra steps to determine
+the output styles
+
+```ts
+import { griffelPreset, BabelPluginMetadata } from '@griffel/babel-preset';
+
+const babelFileResult = Babel.transformFromAstSync(babelAST, sourceCode, {
+  babelrc: false,
+  configFile: false,
+  presets: [[griffelPreset, { generateMetadata: true }]],
+
+  filename: options.filename,
+
+  sourceMaps: options.enableSourceMaps,
+  sourceFileName: options.filename,
+  inputSourceMap: options.inputSourceMap,
+});
+
+// metadata
+console.log(babelFileResult.metadata as unknown as BabelPluginMetadata);
+```
+
 ## Troubleshooting
 
 This section focuses mainly on troubleshooting this babel preset in the [microsoft/fluentui](https://github.com/microsoft/fluentui) repo.
diff --git a/packages/babel-preset/src/index.ts b/packages/babel-preset/src/index.ts
index d6f2dd9..e104771 100644
--- a/packages/babel-preset/src/index.ts
+++ b/packages/babel-preset/src/index.ts
@@ -1,14 +1,14 @@
 import { transformPlugin } from './transformPlugin';
 
 import type { ConfigAPI } from '@babel/core';
-import type { BabelPluginOptions } from './types';
+import type { BabelPluginOptions, BabelPluginMetadata } from './types';
 
 export { default as shakerEvaluator } from '@linaria/shaker';
 export { EvalCache, Module } from '@linaria/babel-preset';
 export { configSchema } from './schema';
 
 export type { Evaluator, EvalRule } from '@linaria/babel-preset';
-export type { BabelPluginOptions };
+export type { BabelPluginOptions, BabelPluginMetadata };
 
 export default function griffelPreset(babel: ConfigAPI, options: BabelPluginOptions) {
   return {
diff --git a/packages/babel-preset/src/schema.ts b/packages/babel-preset/src/schema.ts
index 4f31641..71d1ae8 100644
--- a/packages/babel-preset/src/schema.ts
+++ b/packages/babel-preset/src/schema.ts
@@ -6,6 +6,9 @@ export const configSchema: JSONSchema7 = {
 
   type: 'object',
   properties: {
+    generateMetadata: {
+      type: 'boolean',
+    },
     modules: {
       type: 'array',
       items: {
diff --git a/packages/babel-preset/src/transformPlugin.ts b/packages/babel-preset/src/transformPlugin.ts
index a1061e1..dd4cbe9 100644
--- a/packages/babel-preset/src/transformPlugin.ts
+++ b/packages/babel-preset/src/transformPlugin.ts
@@ -2,14 +2,21 @@ import { NodePath, PluginObj, PluginPass, types as t } from '@babel/core';
 import { declare } from '@babel/helper-plugin-utils';
 import { Module } from '@linaria/babel-preset';
 import shakerEvaluator from '@linaria/shaker';
-import { resolveStyleRulesForSlots, GriffelStyle, resolveResetStyleRules } from '@griffel/core';
+import {
+  resolveStyleRulesForSlots,
+  GriffelStyle,
+  resolveResetStyleRules,
+  CSSRulesByBucket,
+  CSSClassesMapBySlot,
+  normalizeCSSBucketEntry,
+} from '@griffel/core';
 import * as path from 'path';
 
 import { normalizeStyleRules } from './assets/normalizeStyleRules';
 import { replaceAssetsWithImports } from './assets/replaceAssetsWithImports';
 import { dedupeCSSRules } from './utils/dedupeCSSRules';
 import { evaluatePaths } from './utils/evaluatePaths';
-import { BabelPluginOptions } from './types';
+import { BabelPluginOptions, BabelPluginMetadata } from './types';
 import { validateOptions } from './validateOptions';
 
 type FunctionKinds = 'makeStyles' | 'makeResetStyles';
@@ -18,8 +25,17 @@ type BabelPluginState = PluginPass & {
   importDeclarationPaths?: NodePath<t.ImportDeclaration>[];
   requireDeclarationPath?: NodePath<t.VariableDeclarator>;
 
-  definitionPaths?: { functionKind: FunctionKinds; path: NodePath<t.Expression | t.SpreadElement> }[];
+  definitionPaths?: {
+    /** The name of the resulting hook from a Griffel call */
+    declaratorId: string;
+    /** The type of Griffel call */
+    functionKind: FunctionKinds;
+    /** The code path of the Griffel call  */
+    path: NodePath<t.Expression | t.SpreadElement>;
+  }[];
   calleePaths?: NodePath<t.Identifier>[];
+  cssEntries?: BabelPluginMetadata['cssEntries'];
+  cssResetEntries?: BabelPluginMetadata['cssResetEntries'];
 };
 
 function getDefinitionPathFromCallExpression(
@@ -62,6 +78,83 @@ function getCalleeFunctionKind(
   return null;
 }
 
+/**
+ * Gets the id of the parent variable declarator
+ */
+function getParentDeclaratorId(path: NodePath<t.CallExpression> | NodePath<t.MemberExpression>): string {
+  const declarator = path.findParent(p => p.isVariableDeclarator());
+  if (declarator?.isVariableDeclarator()) {
+    const id = declarator.get('id');
+    if (id.isIdentifier()) {
+      return id.node.name;
+    }
+  }
+
+  return 'unknownHook';
+}
+
+/**
+ * Extracts CSS rules from evaluated reset styles to metadata
+ */
+function buildCSSResetEntriesMetadata(
+  state: Required<BabelPluginState>,
+  cssRules: string[] | CSSRulesByBucket,
+  declaratorId: string,
+) {
+  const cssRulesByBucket: CSSRulesByBucket = Array.isArray(cssRules) ? { d: cssRules } : cssRules;
+  state.cssResetEntries[declaratorId] ??= [];
+  state.cssResetEntries[declaratorId] = Object.values(cssRulesByBucket).flatMap(bucketEntries => {
+    return bucketEntries.map(bucketEntry => {
+      if (Array.isArray(bucketEntry)) {
+        throw new Error(
+          `CSS rules in buckets for "makeResetStyles()" should not contain arrays, got: ${JSON.stringify(
+            bucketEntry,
+          )})}`,
+        );
+      }
+
+      return bucketEntry;
+    });
+  });
+}
+
+/**
+ * Extracts CSS rules from evaluated styles to metadata
+ */
+function buildCSSEntriesMetadata(
+  state: Required<BabelPluginState>,
+  classnamesMapping: CSSClassesMapBySlot<string>,
+  cssRulesByBucket: CSSRulesByBucket,
+  declaratorId: string,
+) {
+  const classesBySlot: Record<string, string[]> = Object.fromEntries(
+    Object.entries(classnamesMapping).map(([slot, cssClassesMap]) => {
+      return [
+        slot,
+        Object.values(cssClassesMap).flatMap(cssClasses => (Array.isArray(cssClasses) ? cssClasses : [cssClasses])),
+      ];
+    }),
+  );
+
+  const cssRules: string[] = Object.values(cssRulesByBucket).flatMap(cssRulesByBucket => {
+    return cssRulesByBucket.map(bucketEntry => {
+      const [cssRule] = normalizeCSSBucketEntry(bucketEntry);
+      return cssRule;
+    });
+  });
+
+  state.cssEntries[declaratorId] = Object.fromEntries(
+    Object.entries(classesBySlot).map(([slot, cssClasses]) => {
+      return [
+        slot,
+        cssClasses.map(cssClass => {
+          return cssRules.find(rule => rule.includes(cssClass))!;
+        }),
+      ];
+    }),
+  );
+}
+
 /**
  * Checks if import statement import makeStyles().
  */
@@ -107,6 +200,7 @@ export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<Ba
 
   const pluginOptions: Required<BabelPluginOptions> = {
     babelOptions: {},
+    generateMetadata: false,
     modules: [
       { moduleSource: '@griffel/react', importName: 'makeStyles' },
       { moduleSource: '@fluentui/react-components', importName: 'makeStyles' },
@@ -132,6 +226,8 @@ export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<Ba
       this.importDeclarationPaths = [];
       this.definitionPaths = [];
       this.calleePaths = [];
+      this.cssEntries = {};
+      this.cssResetEntries = {};
     },
 
     visitor: {
@@ -196,6 +292,15 @@ export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<Ba
                 );
                 const uniqueCSSRules = dedupeCSSRules(cssRulesByBucket);
 
+                if (pluginOptions.generateMetadata) {
+                  buildCSSEntriesMetadata(
+                    state as Required<BabelPluginState>,
+                    classnamesMapping,
+                    uniqueCSSRules,
+                    definitionPath.declaratorId,
+                  );
+                }
+
                 (callExpressionPath.get('arguments.0') as NodePath).remove();
                 callExpressionPath.pushContainer('arguments', [
                   t.valueToNode(classnamesMapping),
@@ -218,6 +323,14 @@ export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<Ba
                   ),
                 );
 
+                if (pluginOptions.generateMetadata) {
+                  buildCSSResetEntriesMetadata(
+                    state as Required<BabelPluginState>,
+                    cssRules,
+                    definitionPath.declaratorId,
+                  );
+                }
+
                 (callExpressionPath.get('arguments.0') as NodePath).remove();
                 callExpressionPath.pushContainer('arguments', [
                   t.valueToNode(ltrClassName),
@@ -228,6 +341,13 @@ export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<Ba
 
               replaceAssetsWithImports(pluginOptions.projectRoot, state.filename!, programPath, callExpressionPath);
             });
+
+            if (pluginOptions.generateMetadata) {
+              Object.assign(this.file.metadata, {
+                cssResetEntries: state.cssResetEntries ?? {},
+                cssEntries: state.cssEntries ?? {},
+              } as BabelPluginMetadata);
+            }
           }
 
           state.importDeclarationPaths!.forEach(importDeclarationPath => {
@@ -296,9 +416,11 @@ export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<Ba
 
         if (calleePath.isIdentifier()) {
           const functionKind = getCalleeFunctionKind(calleePath, pluginOptions.modules);
+          const declaratorId = getParentDeclaratorId(path);
 
           if (functionKind) {
             state.definitionPaths!.push({
+              declaratorId,
               functionKind,
               path: getDefinitionPathFromCallExpression(functionKind, path),
             });
@@ -343,8 +465,10 @@ export const transformPlugin = declare<Partial<BabelPluginOptions>, PluginObj<Ba
           return;
         }
 
+        const declaratorId = getParentDeclaratorId(expressionPath);
         state.definitionPaths!.push({
           functionKind,
+          declaratorId,
           path: getDefinitionPathFromCallExpression(functionKind, parentPath),
         });
         state.calleePaths!.push(propertyPath as NodePath<t.Identifier>);
diff --git a/packages/babel-preset/src/types.ts b/packages/babel-preset/src/types.ts
index 5ac91d6..3107cb2 100644
--- a/packages/babel-preset/src/types.ts
+++ b/packages/babel-preset/src/types.ts
@@ -2,6 +2,11 @@ import type { TransformOptions } from '@babel/core';
 import type { EvalRule } from '@linaria/babel-preset';
 
 export type BabelPluginOptions = {
+  /**
+   * Returns the evaluated CSS rules in the file result metadata
+   * @default false
+   */
+  generateMetadata?: boolean;
   /** Defines set of modules and imports handled by a transformPlugin. */
   modules?: {
     moduleSource: string;
@@ -26,3 +31,8 @@ export type BabelPluginOptions = {
    */
   projectRoot?: string;
 };
+
+export type BabelPluginMetadata = {
+  cssEntries: Record<string, Record<string, string[]>>;
+  cssResetEntries: Record<string, string[]>;
+};


Test patch:
diff --git a/packages/babel-preset/src/transformPlugin.test.ts b/packages/babel-preset/src/transformPlugin.test.ts
index c038021..80e2625 100644
--- a/packages/babel-preset/src/transformPlugin.test.ts
+++ b/packages/babel-preset/src/transformPlugin.test.ts
@@ -1,3 +1,4 @@
+import * as Babel from '@babel/core';
 import pluginTester, { prettierFormatter } from 'babel-plugin-tester';
 import * as fs from 'fs';
 import * as path from 'path';
@@ -266,3 +267,80 @@ pluginTester({
   plugin: transformPlugin,
   pluginName: '@griffel/babel-plugin-transform',
 });
+
+// TODO use the plugin tester and all existing fixtures once there is support for that
+// https://github.com/babel-utils/babel-plugin-tester/issues/186
+describe('babel preset', () => {
+  it('should not generate metadata when not configured', () => {
+    const fixture = path.resolve(fixturesDir, 'object', 'code.ts');
+    const code = fs.readFileSync(fixture).toString();
+
+    const babelFileResult = Babel.transformSync(code, {
+      babelrc: false,
+      configFile: false,
+      plugins: [[transformPlugin]],
+      filename: fixture,
+      presets: ['@babel/typescript'],
+    });
+
+    expect(babelFileResult?.metadata).toMatchInlineSnapshot(`Object {}`);
+  });
+
+  it('should generate metadata for makeStyles when configured', () => {
+    const fixture = path.resolve(fixturesDir, 'object', 'code.ts');
+    const code = fs.readFileSync(fixture).toString();
+
+    const babelFileResult = Babel.transformSync(code, {
+      babelrc: false,
+      configFile: false,
+      plugins: [[transformPlugin, { generateMetadata: true }]],
+      filename: fixture,
+      presets: ['@babel/typescript'],
+    });
+
+    expect(babelFileResult?.metadata).toMatchInlineSnapshot(`
+      Object {
+        cssEntries: Object {
+          useStyles: Object {
+            icon: Array [
+              .fcnqdeg{background-color:green;},
+              .fjf1xye{margin-left:4px;},
+              .f8zmjen{margin-right:4px;},
+            ],
+            root: Array [
+              .fe3e8s9{color:red;},
+              .fycuoez{padding-left:4px;},
+              .f8wuabp{padding-right:4px;},
+            ],
+          },
+        },
+        cssResetEntries: Object {},
+      }
+    `);
+  });
+
+  it('should generate metadata for makeResetStyles when configured', () => {
+    const fixture = path.resolve(fixturesDir, 'reset-styles', 'code.ts');
+    const code = fs.readFileSync(fixture).toString();
+
+    const babelFileResult = Babel.transformSync(code, {
+      babelrc: false,
+      configFile: false,
+      plugins: [[transformPlugin, { generateMetadata: true }]],
+      filename: fixture,
+      presets: ['@babel/typescript'],
+    });
+
+    expect(babelFileResult?.metadata).toMatchInlineSnapshot(`
+      Object {
+        cssEntries: Object {},
+        cssResetEntries: Object {
+          useStyles: Array [
+            .rjefjbm{color:red;padding-left:4px;},
+            .r7z97ji{color:red;padding-right:4px;},
+          ],
+        },
+      }
+    `);
+  });
+});
diff --git a/packages/babel-preset/src/validateOptions.test.ts b/packages/babel-preset/src/validateOptions.test.ts
index e3769c0..fc7c594 100644
--- a/packages/babel-preset/src/validateOptions.test.ts
+++ b/packages/babel-preset/src/validateOptions.test.ts
@@ -35,7 +35,7 @@ describe('validateOptions', () => {
       expect(() => validateOptions(pluginOptions)).not.toThrow();
     });
 
-    it('throws on wrong options', () => {
+    it('throws when babelOptions is not valid', () => {
       const pluginOptions = { babelOptions: [] };
 
       // @ts-expect-error Invalid options are passed for testing purposes
@@ -43,6 +43,15 @@ describe('validateOptions', () => {
         `"Validation failed for passed config: data/babelOptions must be object"`,
       );
     });
+
+    it('throws when generateMetadata is not valid', () => {
+      const pluginOptions = { generateMetadata: 3 };
+
+      // @ts-expect-error Invalid options are passed for testing purposes
+      expect(() => validateOptions(pluginOptions)).toThrowErrorMatchingInlineSnapshot(
+        `"Validation failed for passed config: data/generateMetadata must be boolean"`,
+      );
+    });
   });
 
   describe('evaluationRules', () => {