Repository: microsoft/griffel
Pull request #471 (2023-12-08 13:35:26)

feat: add processor for wyw-in-js
Adds a custom processor for [wyw-in-js](https://wyw-in-js.dev/) that supports compilation of `makeStyles()` and `makeResetStyles()` calls.

Is a prerequisite to support Vite (#33). This PR just adds an adapter, E2E tests will be come in a separate PR. We will move to use wyw-in-js for Webpack loader, too (#414).

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

| Package & Exports                                                                                                 |    Baseline (minified/GZIP) |                          PR |                                                                                                 Change |
| :---------------------------------------------------------------------------------------------------------------- | --------------------------: | --------------------------: | -----------------------------------------------------------------------------------------------------: |
| <samp>react</samp> <br /> <abbr title='bundle-size/__css.fixture.js'>\_\_css</abbr>                               |     `1.645 kB`<br />`767 B` |     `1.645 kB`<br />`768 B` |  <br />`1 B` <img aria-hidden="true" src="https://microsoft.github.io/monosize/images/increase.png" /> |
| <samp>react</samp> <br /> <abbr title='bundle-size/__styles.fixture.js'>\_\_styles</abbr>                         |  `3.768 kB`<br />`1.666 kB` |  `3.768 kB`<br />`1.661 kB` | <br />`-5 B` <img aria-hidden="true" src="https://microsoft.github.io/monosize/images/decrease.png" /> |
| <samp>react</samp> <br /> <abbr title='bundle-size/makeResetStyles.fixture.js'>makeResetStyles (runtime)</abbr>   | `19.172 kB`<br />`7.357 kB` | `19.172 kB`<br />`7.377 kB` | <br />`20 B` <img aria-hidden="true" src="https://microsoft.github.io/monosize/images/increase.png" /> |
| <samp>react</samp> <br /> <abbr title='bundle-size/makeStaticStyles.fixture.js'>makeStaticStyles (runtime)</abbr> |  `9.136 kB`<br />`3.989 kB` |  `9.136 kB`<br />`4.007 kB` | <br />`18 B` <img aria-hidden="true" src="https://microsoft.github.io/monosize/images/increase.png" /> |
| <samp>react</samp> <br /> <abbr title='bundle-size/makeStyles.fixture.js'>makeStyles (runtime)</abbr>             | `23.643 kB`<br />`8.953 kB` | `23.643 kB`<br />`8.979 kB` | <br />`26 B` <img aria-hidden="true" src="https://microsoft.github.io/monosize/images/increase.png" /> |

<details>
<summary>Unchanged fixtures</summary>

| Package & Exports                                                                                                          |       Size (minified/GZIP) |
| -------------------------------------------------------------------------------------------------------------------------- | -------------------------: |
| <samp>shadow-dom</samp> <br /> <abbr title='bundle-size/createShadowDOMRenderer.fixture.js'>createShadowDOMRenderer</abbr> | `3.236 kB`<br />`1.408 kB` |

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

<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/change/@griffel-linaria-processor-b20f4e52-7e3f-4efc-ac1d-6157f9fb99ee.json b/change/@griffel-linaria-processor-b20f4e52-7e3f-4efc-ac1d-6157f9fb99ee.json
new file mode 100644
index 0000000..66ec17b
--- /dev/null
+++ b/change/@griffel-linaria-processor-b20f4e52-7e3f-4efc-ac1d-6157f9fb99ee.json
@@ -0,0 +1,7 @@
+{
+  "type": "major",
+  "comment": "chore: initial release",
+  "packageName": "@griffel/tag-processor",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/package.json b/package.json
index 930fa94..aec4b7b 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
     "type-check": "nx affected --target=type-check"
   },
   "devDependencies": {
+    "@babel/generator": "^7.23.0",
     "@babel/preset-react": "^7.22.15",
     "@babel/preset-typescript": "7.23.2",
     "@codesandbox/sandpack-react": "1.18.4",
@@ -73,6 +74,7 @@
     "@typescript-eslint/eslint-plugin": "5.47.0",
     "@typescript-eslint/parser": "5.47.0",
     "@uifabric/merge-styles": "7.19.1",
+    "@wyw-in-js/processor-utils": "^0.2.2",
     "babel-jest": "28.1.3",
     "babel-loader": "8.1.0",
     "babel-plugin-annotate-pure-calls": "^0.4.0",
diff --git a/packages/tag-processor/.eslintrc.json b/packages/tag-processor/.eslintrc.json
new file mode 100644
index 0000000..9d9c0db
--- /dev/null
+++ b/packages/tag-processor/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+  "extends": ["../../.eslintrc.json"],
+  "ignorePatterns": ["!**/*"],
+  "overrides": [
+    {
+      "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+      "rules": {}
+    },
+    {
+      "files": ["*.ts", "*.tsx"],
+      "rules": {}
+    },
+    {
+      "files": ["*.js", "*.jsx"],
+      "rules": {}
+    }
+  ]
+}
diff --git a/packages/tag-processor/README.md b/packages/tag-processor/README.md
new file mode 100644
index 0000000..387950a
--- /dev/null
+++ b/packages/tag-processor/README.md
@@ -0,0 +1,55 @@
+# Griffel processor for wyw-in-js
+
+A processor for [wyw-in-js](https://github.com/Anber/wyw-in-js) for that performs build time transforms for `makeStyles` & `makeResetStyles` [`@griffel/react`](../react).
+
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+- [Install](#install)
+- [How to use it?](#how-to-use-it)
+  - [Handling Griffel re-exports](#handling-griffel-re-exports)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+## Install
+
+```bash
+yarn add --dev @griffel/tag-processor
+# or
+npm install --save-dev @griffel/tag-processor
+```
+
+## How to use it?
+
+This package cannot be used solely, it should be paired with `@griffel/babel-preset` or `@griffel/webpack-loader`
+
+- For library developers, please use [`@griffel/babel-preset`](../babel-preset)
+- For application developers, please use [`@griffel/webpack-loader`](../webpack-loader)
+
+### Handling Griffel re-exports
+
+```js
+import { makeStyles, makeResetStyles } from 'custom-package';
+```
+
+By default, the processor handles imports from `@griffel/react` & `@fluentui/react-components`, to handle imports from custom packages settings you need to include meta information to a matching `package.json`:
+
+```json
+{
+  "name": "custom-package",
+  "version": "1.0.0",
+  "wyw-in-js": {
+    "tags": {
+      "makeStyles": "@griffel/tag-processor/make-styles",
+      "makeResetStyles": "@griffel/tag-processor/make-reset-styles"
+    }
+  }
+}
+```
+
+> **Note**: "custom-package" should re-export following functions from `@griffel/react`:
+>
+> - `__styles`
+> - `__css`
+> - `__resetStyles`
+> - `__resetCSS`
diff --git a/packages/tag-processor/jest.config.js b/packages/tag-processor/jest.config.js
new file mode 100644
index 0000000..8fb101d
--- /dev/null
+++ b/packages/tag-processor/jest.config.js
@@ -0,0 +1,14 @@
+module.exports = {
+  displayName: 'tag-processor',
+  preset: '../../jest.preset.js',
+  globals: {
+    'ts-jest': {
+      tsconfig: '<rootDir>/tsconfig.spec.json',
+    },
+  },
+  transform: {
+    '^.+\\.[tj]sx?$': 'ts-jest',
+  },
+  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
+  coverageDirectory: '../../coverage/packages/tag-processor',
+};
diff --git a/packages/tag-processor/package.json b/packages/tag-processor/package.json
new file mode 100644
index 0000000..0a45607
--- /dev/null
+++ b/packages/tag-processor/package.json
@@ -0,0 +1,30 @@
+{
+  "name": "@griffel/tag-processor",
+  "version": "0.0.1",
+  "description": "wyw-in-js processor for Griffel",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/microsoft/griffel"
+  },
+  "dependencies": {
+    "@griffel/core": "^1.15.1",
+    "@wyw-in-js/processor-utils": "^0.2.2",
+    "stylis": "^4.2.0",
+    "tslib": "^2.1.0"
+  },
+  "exports": {
+    ".": {
+      "types": "./src/index.d.ts",
+      "default": "./src/index.js"
+    },
+    "./make-styles": {
+      "types": "./src/MakeStylesProcessor.d.ts",
+      "default": "./src/MakeStylesProcessor.js"
+    },
+    "./make-reset-styles": {
+      "types": "./src/MakeResetStylesProcessor.d.ts",
+      "default": "./src/MakeResetStylesProcessor.js"
+    }
+  }
+}
diff --git a/packages/tag-processor/project.json b/packages/tag-processor/project.json
new file mode 100644
index 0000000..1dcfc05
--- /dev/null
+++ b/packages/tag-processor/project.json
@@ -0,0 +1,49 @@
+{
+  "root": "packages/tag-processor",
+  "sourceRoot": "packages/tag-processor/src",
+  "projectType": "library",
+  "targets": {
+    "lint": {
+      "executor": "@nrwl/linter:eslint",
+      "outputs": ["{options.outputFile}"],
+      "options": {
+        "lintFilePatterns": ["packages/tag-processor/**/*.ts"]
+      }
+    },
+    "test": {
+      "executor": "@nrwl/jest:jest",
+      "outputs": ["coverage/packages/tag-processor"],
+      "options": {
+        "jestConfig": "packages/tag-processor/jest.config.js",
+        "passWithNoTests": true
+      }
+    },
+    "build": {
+      "executor": "@nrwl/js:tsc",
+      "outputs": ["{options.outputPath}"],
+      "options": {
+        "outputPath": "dist/packages/tag-processor",
+        "tsConfig": "packages/tag-processor/tsconfig.lib.json",
+        "packageJson": "packages/tag-processor/package.json",
+        "main": "packages/tag-processor/src/index.ts",
+        "assets": [
+          "packages/tag-processor/README.md",
+          {
+            "glob": "LICENSE.md",
+            "input": ".",
+            "output": "."
+          }
+        ]
+      }
+    },
+    "type-check": {
+      "executor": "@nrwl/workspace:run-commands",
+      "options": {
+        "cwd": "packages/tag-processor",
+        "commands": [{ "command": "tsc -b --pretty" }],
+        "outputPath": []
+      }
+    }
+  },
+  "tags": []
+}
diff --git a/packages/tag-processor/src/BaseGriffelProcessor.ts b/packages/tag-processor/src/BaseGriffelProcessor.ts
new file mode 100644
index 0000000..bfc2d3f
--- /dev/null
+++ b/packages/tag-processor/src/BaseGriffelProcessor.ts
@@ -0,0 +1,47 @@
+import type { Expression } from '@babel/types';
+import type { Params, TailProcessorParams } from '@wyw-in-js/processor-utils';
+import { BaseProcessor, TaggedTemplateProcessor, validateParams } from '@wyw-in-js/processor-utils';
+import * as path from 'path';
+
+export default abstract class BaseGriffelProcessor extends BaseProcessor {
+  readonly expressionName: string | number | boolean | null = null;
+
+  public constructor([tag, callParam]: Params, ...args: TailProcessorParams) {
+    super([tag], ...args);
+
+    validateParams([tag, callParam], ['callee', 'call'], TaggedTemplateProcessor.SKIP);
+
+    if (callParam[0] === 'call') {
+      const { ex } = callParam[1];
+
+      if (ex.type === 'Identifier') {
+        this.dependencies.push(callParam[1]);
+        this.expressionName = ex.name;
+      } else if (ex.type === 'NullLiteral') {
+        this.expressionName = null;
+      } else {
+        this.expressionName = ex.value;
+      }
+    }
+  }
+
+  public get path() {
+    return process.platform === 'win32' ? path.win32 : path.posix;
+  }
+
+  public override get asSelector(): string {
+    throw new Error('The result of makeStyles cannot be used as a selector.');
+  }
+
+  public override doEvaltimeReplacement(): void {
+    this.replacer(this.value, false);
+  }
+
+  public override get value(): Expression {
+    return this.astService.nullLiteral();
+  }
+
+  public override toString(): string {
+    return `${super.toString()}(…)`;
+  }
+}
diff --git a/packages/tag-processor/src/MakeResetStylesProcessor.ts b/packages/tag-processor/src/MakeResetStylesProcessor.ts
new file mode 100644
index 0000000..1dd9cdf
--- /dev/null
+++ b/packages/tag-processor/src/MakeResetStylesProcessor.ts
@@ -0,0 +1,66 @@
+import { resolveResetStyleRules } from '@griffel/core';
+import type { CSSRulesByBucket, GriffelResetStyle } from '@griffel/core';
+import type { ValueCache } from '@wyw-in-js/processor-utils';
+
+import { createRuleLiteral } from './assets/createRuleLiteral';
+import { normalizeStyleRules } from './assets/normalizeStyleRules';
+import BaseGriffelProcessor from './BaseGriffelProcessor';
+import { FileContext } from './types';
+
+export default class MakeResetStylesProcessor extends BaseGriffelProcessor {
+  #ltrClassName: string | null = null;
+  #rtlClassName: string | null = null;
+  #cssRules: CSSRulesByBucket | string[] | null = null;
+
+  public override build(valueCache: ValueCache) {
+    const styles = valueCache.get(this.expressionName) as GriffelResetStyle;
+
+    [this.#ltrClassName, this.#rtlClassName, this.#cssRules] = resolveResetStyleRules(
+      // Heads up!
+      // Style rules should be normalized *before* they will be resolved to CSS rules to have deterministic
+      // results across different build targets.
+      normalizeStyleRules(this.path, this.context as FileContext, styles),
+    );
+  }
+
+  public override doRuntimeReplacement(): void {
+    if (!this.#cssRules || !this.#ltrClassName) {
+      throw new Error('Styles are not extracted yet. Please call `build` first.');
+    }
+
+    const t = this.astService;
+    const addAssetImport = (path: string) => t.addDefaultImport(path, 'asset');
+
+    let rulesExpression;
+
+    if (Array.isArray(this.#cssRules)) {
+      rulesExpression = t.arrayExpression(
+        this.#cssRules.map(rule => {
+          return createRuleLiteral(this.path, t, this.context as FileContext, rule, addAssetImport);
+        }),
+      );
+    } else {
+      rulesExpression = t.objectExpression(
+        Object.entries(this.#cssRules).map(([bucketName, cssRules]) =>
+          t.objectProperty(
+            t.identifier(bucketName),
+            t.arrayExpression(
+              cssRules.map(rule => {
+                return createRuleLiteral(this.path, t, this.context as FileContext, rule as string, addAssetImport);
+              }),
+            ),
+          ),
+        ),
+      );
+    }
+
+    const stylesImportIdentifier = t.addNamedImport('__resetStyles', this.tagSource.source);
+    const stylesCallExpression = t.callExpression(stylesImportIdentifier, [
+      t.stringLiteral(this.#ltrClassName),
+      this.#rtlClassName ? t.stringLiteral(this.#rtlClassName) : t.nullLiteral(),
+      rulesExpression,
+    ]);
+
+    this.replacer(stylesCallExpression, true);
+  }
+}
diff --git a/packages/tag-processor/src/MakeStylesProcessor.ts b/packages/tag-processor/src/MakeStylesProcessor.ts
new file mode 100644
index 0000000..d8413ab
--- /dev/null
+++ b/packages/tag-processor/src/MakeStylesProcessor.ts
@@ -0,0 +1,69 @@
+import { GriffelStyle, resolveStyleRulesForSlots } from '@griffel/core';
+import type { CSSClassesMapBySlot, CSSRulesByBucket } from '@griffel/core';
+import type { ValueCache } from '@wyw-in-js/processor-utils';
+
+import { createRuleLiteral } from './assets/createRuleLiteral';
+import { normalizeStyleRules } from './assets/normalizeStyleRules';
+import BaseGriffelProcessor from './BaseGriffelProcessor';
+import type { FileContext } from './types';
+import { dedupeCSSRules } from './utils/dedupeCSSRules';
+
+export default class MakeStylesProcessor extends BaseGriffelProcessor {
+  #cssClassMap: CSSClassesMapBySlot<string> | undefined;
+  #cssRulesByBucket: CSSRulesByBucket | undefined;
+
+  public override build(valueCache: ValueCache) {
+    const stylesBySlots = valueCache.get(this.expressionName) as Record<string /* slot */, GriffelStyle>;
+
+    [this.#cssClassMap, this.#cssRulesByBucket] = resolveStyleRulesForSlots(
+      // Heads up!
+      // Style rules should be normalized *before* they will be resolved to CSS rules to have deterministic
+      // results across different build targets.
+      normalizeStyleRules(this.path, this.context as FileContext, stylesBySlots),
+    );
+  }
+
+  public override doRuntimeReplacement(): void {
+    if (!this.#cssClassMap || !this.#cssRulesByBucket) {
+      throw new Error('Styles are not extracted yet. Please call `build` first.');
+    }
+
+    const t = this.astService;
+    const addAssetImport = (path: string) => t.addDefaultImport(path, 'asset');
+
+    const uniqueRules = dedupeCSSRules(this.#cssRulesByBucket);
+    const rulesObjectExpression = t.objectExpression(
+      Object.entries(uniqueRules).map(([bucketName, cssRules]) =>
+        t.objectProperty(
+          t.identifier(bucketName),
+          t.arrayExpression(
+            cssRules.map(rule => {
+              if (typeof rule === 'string') {
+                return createRuleLiteral(this.path, t, this.context as FileContext, rule, addAssetImport);
+              }
+
+              const [cssRule, metadata] = rule;
+
+              return t.arrayExpression([
+                createRuleLiteral(this.path, t, this.context as FileContext, cssRule, addAssetImport),
+                t.objectExpression(
+                  Object.entries(metadata).map(([key, value]) =>
+                    t.objectProperty(t.identifier(key), t.stringLiteral(value as string)),
+                  ),
+                ),
+              ]);
+            }),
+          ),
+        ),
+      ),
+    );
+
+    const stylesImportIdentifier = t.addNamedImport('__styles', this.tagSource.source);
+    const stylesCallExpression = t.callExpression(stylesImportIdentifier, [
+      t.valueToNode(this.#cssClassMap),
+      rulesObjectExpression,
+    ]);
+
+    this.replacer(stylesCallExpression, true);
+  }
+}
diff --git a/packages/tag-processor/src/assets/createRuleLiteral.ts b/packages/tag-processor/src/assets/createRuleLiteral.ts
new file mode 100644
index 0000000..fbe7252
--- /dev/null
+++ b/packages/tag-processor/src/assets/createRuleLiteral.ts
@@ -0,0 +1,51 @@
+import type { types as t } from '@babel/core';
+import { tokenize } from 'stylis';
+
+import { relativePathToImportLike } from './relativePathToImportLike';
+import type { FileContext } from '../types';
+
+export function createRuleLiteral(
+  path: typeof import('path'),
+  astService: typeof t,
+  fileContext: FileContext,
+  rule: string,
+  addAssetImport: (path: string) => t.Identifier,
+): t.StringLiteral | t.TemplateLiteral {
+  if (rule.indexOf('url(') === -1) {
+    return astService.stringLiteral(rule);
+  }
+
+  const tokens = tokenize(rule);
+
+  const quasis: t.TemplateElement[] = [];
+  const expressions: t.Identifier[] = [];
+
+  let acc = '';
+
+  for (let i = 0, l = tokens.length; i < l; i++) {
+    acc += tokens[i];
+
+    if (tokens[i] === 'url') {
+      const url = tokens[i + 1].slice(1, -1);
+
+      if (url.startsWith('griffel:')) {
+        // Handle `filter: url(./a.svg#id)`
+        const [pathname, hash] = url.slice(8).split('#');
+
+        quasis.push(astService.templateElement({ raw: acc + '(' }, false));
+
+        const importPath = relativePathToImportLike(path, fileContext, pathname);
+        const importName = addAssetImport(importPath);
+
+        expressions.push(importName);
+
+        acc = `${hash ? `#${hash}` : ''})`;
+        i++;
+      }
+    }
+  }
+
+  quasis.push(astService.templateElement({ raw: acc }, true));
+
+  return astService.templateLiteral(quasis, expressions);
+}
diff --git a/packages/tag-processor/src/assets/normalizeStyleRules.ts b/packages/tag-processor/src/assets/normalizeStyleRules.ts
new file mode 100644
index 0000000..f6bab71
--- /dev/null
+++ b/packages/tag-processor/src/assets/normalizeStyleRules.ts
@@ -0,0 +1,76 @@
+import type { GriffelAnimation, GriffelResetStyle, GriffelStyle } from '@griffel/core';
+import { tokenize } from 'stylis';
+
+import { FileContext } from '../types';
+
+/**
+ * Linaria v4 emits absolute paths for assets, we normalize these paths to be relative from the project root to be the
+ * same if an assets was used in different files.
+ */
+export function normalizeAssetPath(path: typeof import('path'), fileContext: FileContext, absoluteAssetPath: string) {
+  // Normalize paths to be POSIX-like to be independent of an execution environment
+  return path.relative(fileContext.root, absoluteAssetPath).split(path.sep).join(path.posix.sep);
+}
+
+export function normalizeStyleRule(path: typeof import('path'), fileContext: FileContext, ruleValue: string | number) {
+  if (typeof ruleValue === 'number' || ruleValue.indexOf('url(') === -1) {
+    return ruleValue;
+  }
+
+  return tokenize(ruleValue).reduce((result, token, index, array) => {
+    if (token === 'url') {
+      const url = array[index + 1].slice(1, -1);
+      const isFilePath = url.replace(/^['|"]/, '').startsWith(fileContext.root);
+
+      if (isFilePath) {
+        array[index + 1] = `(griffel:${normalizeAssetPath(
+          path,
+          fileContext,
+          // Quotes in URL are optional, so we can also normalize them as we know that it's a file path
+          // https://www.w3.org/TR/CSS2/syndata.html#value-def-uri
+          url.replace(/^['|"](.+)['|"]$/, '$1'),
+        )})`;
+      } else {
+        // Always replace with normalized value, so @griffel/core can de-duplicate them.
+        array[index + 1] = `(${url})`;
+      }
+    }
+
+    return result + token;
+  }, '');
+}
+
+export function normalizeStyleRules(
+  path: typeof import('path'),
+  fileContext: FileContext,
+  stylesBySlots: Record<string /* slot */, GriffelStyle> | GriffelStyle | GriffelResetStyle,
+): Record<string /* slot */, GriffelStyle> {
+  return Object.fromEntries(
+    Object.entries(stylesBySlots).map(([key, value]) => {
+      if (typeof value === 'undefined') {
+        return [key, value];
+      }
+
+      // Fallback values or keyframes
+      if (Array.isArray(value)) {
+        return [
+          key,
+          value.map(rule => {
+            if (typeof rule === 'object') {
+              return normalizeStyleRules(path, fileContext, rule as GriffelAnimation);
+            }
+
+            return normalizeStyleRule(path, fileContext, rule);
+          }),
+        ];
+      }
+
+      // Nested objects
+      if (typeof value === 'object') {
+        return [key, normalizeStyleRules(path, fileContext, value as unknown as GriffelStyle)];
+      }
+
+      return [key, normalizeStyleRule(path, fileContext, value)];
+    }),
+  );
+}
diff --git a/packages/tag-processor/src/assets/relativePathToImportLike.ts b/packages/tag-processor/src/assets/relativePathToImportLike.ts
new file mode 100644
index 0000000..f8a946c
--- /dev/null
+++ b/packages/tag-processor/src/assets/relativePathToImportLike.ts
@@ -0,0 +1,16 @@
+import type { FileContext } from '../types';
+
+export function relativePathToImportLike(path: typeof import('path'), fileContext: FileContext, assetPath: string) {
+  const fileDirectory = path.dirname(fileContext.filename);
+  const absoluteAssetPath = path.resolve(fileContext.root, assetPath);
+
+  let relativeAssetPath = path.relative(fileDirectory, absoluteAssetPath);
+
+  if (!relativeAssetPath.startsWith('..' + path.sep)) {
+    relativeAssetPath = './' + relativeAssetPath;
+  }
+
+  // Normalize paths to be POSIX-like as bundlers don't handle Windows paths
+  // "path.posix" does not make sense there as there is no "windows-to-posix-path" function
+  return relativeAssetPath.split(path.sep).join(path.posix.sep);
+}
diff --git a/packages/tag-processor/src/index.ts b/packages/tag-processor/src/index.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/tag-processor/src/index.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/tag-processor/src/types.ts b/packages/tag-processor/src/types.ts
new file mode 100644
index 0000000..8d40090
--- /dev/null
+++ b/packages/tag-processor/src/types.ts
@@ -0,0 +1,4 @@
+export type FileContext = {
+  root: string;
+  filename: string;
+};
diff --git a/packages/tag-processor/src/utils/dedupeCSSRules.ts b/packages/tag-processor/src/utils/dedupeCSSRules.ts
new file mode 100644
index 0000000..16ca498
--- /dev/null
+++ b/packages/tag-processor/src/utils/dedupeCSSRules.ts
@@ -0,0 +1,22 @@
+import type { CSSRulesByBucket } from '@griffel/core';
+
+/**
+ * Rules that are returned by `resolveStyles()` are not deduplicated.
+ * It's critical to filter out duplicates for build-time transform to avoid duplicated rules in a bundle.
+ */
+export function dedupeCSSRules(cssRulesByBucket: CSSRulesByBucket): CSSRulesByBucket {
+  return Object.fromEntries(
+    Object.entries(cssRulesByBucket).map(([styleBucketName, cssBucketEntries]) => {
+      if (styleBucketName === 'm') {
+        return [
+          styleBucketName,
+          cssBucketEntries.filter(
+            (entryA, index, entries) => entries.findIndex(entryB => entryA[0] === entryB[0]) === index,
+          ),
+        ];
+      }
+
+      return [styleBucketName, [...new Set(cssBucketEntries)]];
+    }),
+  );
+}
diff --git a/packages/tag-processor/tsconfig.json b/packages/tag-processor/tsconfig.json
new file mode 100644
index 0000000..42707b2
--- /dev/null
+++ b/packages/tag-processor/tsconfig.json
@@ -0,0 +1,23 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "allowJs": true,
+    "forceConsistentCasingInFileNames": true,
+    "lib": ["ES2019", "dom", "DOM.Iterable"],
+    "strict": true,
+    "noImplicitOverride": true,
+    "noPropertyAccessFromIndexSignature": true,
+    "noImplicitReturns": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "files": [],
+  "include": [],
+  "references": [
+    {
+      "path": "./tsconfig.lib.json"
+    },
+    {
+      "path": "./tsconfig.spec.json"
+    }
+  ]
+}
diff --git a/packages/tag-processor/tsconfig.lib.json b/packages/tag-processor/tsconfig.lib.json
new file mode 100644
index 0000000..8d27d94
--- /dev/null
+++ b/packages/tag-processor/tsconfig.lib.json
@@ -0,0 +1,11 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "module": "commonjs",
+    "outDir": "../../dist/out-tsc",
+    "declaration": true,
+    "types": ["node", "environment"]
+  },
+  "exclude": ["**/*.spec.ts", "**/*.test.ts"],
+  "include": ["**/*.ts"]
+}
diff --git a/packages/tag-processor/tsconfig.spec.json b/packages/tag-processor/tsconfig.spec.json
new file mode 100644
index 0000000..167fe94
--- /dev/null
+++ b/packages/tag-processor/tsconfig.spec.json
@@ -0,0 +1,20 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "jsx": "react",
+    "outDir": "../../dist/out-tsc",
+    "module": "commonjs",
+    "types": ["jest", "node", "environment"]
+  },
+  "include": [
+    "**/*.test.ts",
+    "**/*.spec.ts",
+    "**/*.test.tsx",
+    "**/*.spec.tsx",
+    "**/*.test.js",
+    "**/*.spec.js",
+    "**/*.test.jsx",
+    "**/*.spec.jsx",
+    "**/*.d.ts"
+  ]
+}
diff --git a/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx b/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx
index cd0cebe..7d54944 100644
--- a/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx
+++ b/packages/webpack-loader/__fixtures__/webpack-inherit-resolve-options/color.jsx
@@ -1,3 +1,3 @@
 const color = 'blue';
 
-export default color;
\ No newline at end of file
+export default color;
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 3f99207..6d2bb61 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -24,6 +24,9 @@
       "@griffel/next-extraction-plugin": ["packages/next-extraction-plugin/src/index.ts"],
       "@griffel/postcss-syntax": ["packages/postcss-syntax/src/index.ts"],
       "@griffel/style-types": ["packages/style-types/src/index.ts"],
+      "@griffel/tag-processor": ["packages/tag-processor/src/index.ts"],
+      "@griffel/tag-processor/make-styles": ["packages/tag-processor/src/MakeStylesProcessor.ts"],
+      "@griffel/tag-processor/make-reset-styles": ["packages/tag-processor/src/MakeResetStylesProcessor.ts"],
       "@griffel/react": ["packages/react/src/index.ts"],
       "@griffel/webpack-extraction-plugin": ["packages/webpack-extraction-plugin/src/index.ts"],
       "@griffel/webpack-loader": ["packages/webpack-loader/src/index.ts"]
diff --git a/workspace.json b/workspace.json
index d8ebbb4..63b7bc5 100644
--- a/workspace.json
+++ b/workspace.json
@@ -14,6 +14,7 @@
     "@griffel/postcss-syntax": "packages/postcss-syntax",
     "@griffel/react": "packages/react",
     "@griffel/style-types": "packages/style-types",
+    "@griffel/tag-processor": "packages/tag-processor",
     "@griffel/webpack-extraction-plugin": "packages/webpack-extraction-plugin",
     "@griffel/webpack-loader": "packages/webpack-loader",
     "@griffel/website": "apps/website"
diff --git a/yarn.lock b/yarn.lock
index 47bf8c4..4ecea56 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -246,15 +246,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/generator@npm:>=7, @babel/generator@npm:^7.12.11, @babel/generator@npm:^7.12.5, @babel/generator@npm:^7.17.9, @babel/generator@npm:^7.18.7, @babel/generator@npm:^7.23.0, @babel/generator@npm:^7.7.2":
-  version: 7.23.0
-  resolution: "@babel/generator@npm:7.23.0"
+"@babel/generator@npm:>=7, @babel/generator@npm:^7.12.11, @babel/generator@npm:^7.12.5, @babel/generator@npm:^7.17.9, @babel/generator@npm:^7.18.7, @babel/generator@npm:^7.23.0, @babel/generator@npm:^7.23.5, @babel/generator@npm:^7.7.2":
+  version: 7.23.5
+  resolution: "@babel/generator@npm:7.23.5"
   dependencies:
-    "@babel/types": "npm:^7.23.0"
+    "@babel/types": "npm:^7.23.5"
     "@jridgewell/gen-mapping": "npm:^0.3.2"
     "@jridgewell/trace-mapping": "npm:^0.3.17"
     jsesc: "npm:^2.5.1"
-  checksum: bd1598bd356756065d90ce26968dd464ac2b915c67623f6f071fb487da5f9eb454031a380e20e7c9a7ce5c4a49d23be6cb9efde404952b0b3f3c0c3a9b73d68a
+  checksum: 094af79c2e8fdb0cfd06b42ff6a39a8a95639bc987cace44f52ed5c46127f5469eb20ab5f4c8991fc00fa9c1445a1977cde8e44289d6be29ddbb315fb0fc1b45
   languageName: node
   linkType: hard
 
@@ -505,10 +505,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helper-string-parser@npm:^7.22.5":
-  version: 7.22.5
-  resolution: "@babel/helper-string-parser@npm:7.22.5"
-  checksum: 7f275a7f1a9504da06afc33441e219796352a4a3d0288a961bc14d1e30e06833a71621b33c3e60ee3ac1ff3c502d55e392bcbc0665f6f9d2629809696fab7cdd
+"@babel/helper-string-parser@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/helper-string-parser@npm:7.23.4"
+  checksum: c352082474a2ee1d2b812bd116a56b2e8b38065df9678a32a535f151ec6f58e54633cc778778374f10544b930703cca6ddf998803888a636afa27e2658068a9c
   languageName: node
   linkType: hard
 
@@ -1906,14 +1906,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.7, @babel/types@npm:^7.15.6, @babel/types@npm:^7.2.0, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
-  version: 7.23.0
-  resolution: "@babel/types@npm:7.23.0"
+"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.7, @babel/types@npm:^7.15.6, @babel/types@npm:^7.2.0, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.5, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
+  version: 7.23.5
+  resolution: "@babel/types@npm:7.23.5"
   dependencies:
-    "@babel/helper-string-parser": "npm:^7.22.5"
+    "@babel/helper-string-parser": "npm:^7.23.4"
     "@babel/helper-validator-identifier": "npm:^7.22.20"
     to-fast-properties: "npm:^2.0.0"
-  checksum: ca5b896a26c91c5672254725c4c892a35567d2122afc47bd5331d1611a7f9230c19fc9ef591a5a6f80bf0d80737e104a9ac205c96447c74bee01d4319db58001
+  checksum: a623a4e7f396f1903659099da25bfa059694a49f42820f6b5288347f1646f0b37fb7cc550ba45644e9067149368ef34ccb1bd4a4251ec59b83b3f7765088f363
   languageName: node
   linkType: hard
 
@@ -7682,6 +7682,27 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@wyw-in-js/processor-utils@npm:^0.2.2":
+  version: 0.2.2
+  resolution: "@wyw-in-js/processor-utils@npm:0.2.2"
+  dependencies:
+    "@babel/generator": "npm:^7.23.5"
+    "@wyw-in-js/shared": "npm:0.2.2"
+  checksum: 7f2a2c3ec139b93bacc75c32d09ca482fe208b90ad5a5e36443693e3ce820296cf00329d3f43928471bff913cd6570febc0f44f5c1ba704a020f76554a64e57e
+  languageName: node
+  linkType: hard
+
+"@wyw-in-js/shared@npm:0.2.2":
+  version: 0.2.2
+  resolution: "@wyw-in-js/shared@npm:0.2.2"
+  dependencies:
+    debug: "npm:^4.3.4"
+    find-up: "npm:^5.0.0"
+    minimatch: "npm:^9.0.3"
+  checksum: 309749ab7db00a74c426b9a8320c9b94fac45bb1f57ac8db7ebb13411df6788cf04c59b540a230132bdb587694d33aa8e3ad2d3edc1fac405fc3d4f08674092c
+  languageName: node
+  linkType: hard
+
 "@xtuc/ieee754@npm:^1.2.0":
   version: 1.2.0
   resolution: "@xtuc/ieee754@npm:1.2.0"
@@ -14872,6 +14893,7 @@ __metadata:
     "@typescript-eslint/parser": "npm:5.47.0"
     "@typescript-eslint/utils": "npm:^5.47.0"
     "@uifabric/merge-styles": "npm:7.19.1"
+    "@wyw-in-js/processor-utils": "npm:^0.2.2"
     ajv: "npm:^8.4.0"
     babel-jest: "npm:28.1.3"
     babel-loader: "npm:8.1.0"
@@ -19210,6 +19232,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"minimatch@npm:^9.0.3":
+  version: 9.0.3
+  resolution: "minimatch@npm:9.0.3"
+  dependencies:
+    brace-expansion: "npm:^2.0.1"
+  checksum: c81b47d28153e77521877649f4bab48348d10938df9e8147a58111fe00ef89559a2938de9f6632910c4f7bf7bb5cd81191a546167e58d357f0cfb1e18cecc1c5
+  languageName: node
+  linkType: hard
+
 "minimist-options@npm:4.1.0":
   version: 4.1.0
   resolution: "minimist-options@npm:4.1.0"


Test patch:
diff --git a/packages/tag-processor/src/assets/createRuleLiteral.test.ts b/packages/tag-processor/src/assets/createRuleLiteral.test.ts
new file mode 100644
index 0000000..27c6cb1
--- /dev/null
+++ b/packages/tag-processor/src/assets/createRuleLiteral.test.ts
@@ -0,0 +1,61 @@
+import { types as t } from '@babel/core';
+import generate from '@babel/generator';
+import * as path from 'path';
+
+import type { FileContext } from '../types';
+import { createRuleLiteral } from './createRuleLiteral';
+
+const fileContextPosix: FileContext = {
+  root: '/home/projects/foo',
+  filename: '/home/projects/foo/src/styles/Component.styles.ts',
+};
+const fileContextWin32: FileContext = {
+  root: 'C:\\Users\\Foo\\projects',
+  filename: 'C:\\Users\\Foo\\projects\\src\\styles\\Component.styles.ts',
+};
+
+describe('createRuleLiteral', () => {
+  it('creates strings for rules', () => {
+    const addAssetImport = jest.fn();
+
+    const node = createRuleLiteral(path.posix, t, fileContextPosix, '.foo { color: red }', addAssetImport);
+    const { code } = generate(node);
+
+    expect(code).toMatchInlineSnapshot(`"\\".foo { color: red }\\""`);
+    expect(addAssetImport).not.toHaveBeenCalled();
+  });
+
+  describe('updates URLs that start with "griffel"', () => {
+    it('posix', () => {
+      const addAssetImport = jest.fn().mockImplementation(() => t.identifier('foo'));
+
+      const node = createRuleLiteral(
+        path.posix,
+        t,
+        fileContextPosix,
+        '.foo { url(griffel:assets/foo.png) }',
+        addAssetImport,
+      );
+      const { code } = generate(node);
+
+      expect(code).toMatchInlineSnapshot(`"\`.foo { url(\${foo}) }\`"`);
+      expect(addAssetImport).toHaveBeenCalledWith('../../assets/foo.png');
+    });
+
+    it('win32', () => {
+      const addAssetImport = jest.fn().mockImplementation(() => t.identifier('foo'));
+
+      const node = createRuleLiteral(
+        path.win32,
+        t,
+        fileContextWin32,
+        '.foo { url(griffel:assets/foo.png) }',
+        addAssetImport,
+      );
+      const { code } = generate(node);
+
+      expect(code).toMatchInlineSnapshot(`"\`.foo { url(\${foo}) }\`"`);
+      expect(addAssetImport).toHaveBeenCalledWith('../../assets/foo.png');
+    });
+  });
+});
diff --git a/packages/tag-processor/src/assets/normalizeStyleRules.test.ts b/packages/tag-processor/src/assets/normalizeStyleRules.test.ts
new file mode 100644
index 0000000..4b88126
--- /dev/null
+++ b/packages/tag-processor/src/assets/normalizeStyleRules.test.ts
@@ -0,0 +1,187 @@
+import * as path from 'path';
+import { normalizeStyleRule, normalizeStyleRules } from './normalizeStyleRules';
+
+describe('normalizeStyleRule', () => {
+  it('handles rules without quotes', () => {
+    expect(
+      normalizeStyleRule(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        'url(/home/projects/foo/assets/image.png)',
+      ),
+    ).toBe('url(griffel:assets/image.png)');
+  });
+
+  it('handles rules with quotes', () => {
+    expect(
+      normalizeStyleRule(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        "url('/home/projects/foo/assets/image.png')",
+      ),
+    ).toBe('url(griffel:assets/image.png)');
+    expect(
+      normalizeStyleRule(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        'url("/home/projects/foo/assets/image.png")',
+      ),
+    ).toBe('url(griffel:assets/image.png)');
+  });
+
+  it('keeps relative URLs', () => {
+    expect(
+      normalizeStyleRule(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        'url(./foo.img)',
+      ),
+    ).toBe('url(./foo.img)');
+    expect(
+      normalizeStyleRule(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        'url(../foo.img)',
+      ),
+    ).toBe('url(../foo.img)');
+  });
+
+  it('keeps data-url', () => {
+    expect(
+      normalizeStyleRule(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        'url(data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2Q==)',
+      ),
+    ).toBe('url(data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2Q==)');
+  });
+
+  it('handles Windows paths', () => {
+    expect(
+      normalizeStyleRule(
+        path.win32,
+        {
+          root: 'C:\\Users\\Foo\\projects\\bar',
+          filename: 'C:\\Users\\Foo\\projects\\bar\\src\\styles\\Component.styles.ts',
+        },
+        'url(C:\\Users\\Foo\\projects\\bar\\assets\\image.png)',
+      ),
+    ).toBe('url(griffel:assets/image.png)');
+  });
+});
+
+describe('normalizeStyleRules', () => {
+  it('handles rules without metadata', () => {
+    expect(
+      normalizeStyleRules(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        {
+          root: {
+            color: 'red',
+            backgroundImage: 'url(/home/projects/foo/assets/image.jpg)',
+            overflowY: ['hidden', 'scroll'],
+
+            ':hover': {
+              backgroundImage: 'url(/home/projects/foo/assets/hoverImage.jpg)',
+            },
+
+            '@media screen and (max-width: 100px)': {
+              '& .foo': {
+                backgroundImage: 'url(/home/projects/foo/assets/mediaImage.jpg)',
+              },
+            },
+          },
+        },
+      ),
+    ).toEqual({
+      root: {
+        color: 'red',
+        backgroundImage: 'url(griffel:assets/image.jpg)',
+        overflowY: ['hidden', 'scroll'],
+
+        ':hover': {
+          backgroundImage: 'url(griffel:assets/hoverImage.jpg)',
+        },
+
+        '@media screen and (max-width: 100px)': {
+          '& .foo': {
+            backgroundImage: 'url(griffel:assets/mediaImage.jpg)',
+          },
+        },
+      },
+    });
+  });
+
+  it('handles multiple URLs', () => {
+    expect(
+      normalizeStyleRules(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        {
+          root: {
+            // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Backgrounds_and_Borders/Using_multiple_backgrounds
+            backgroundImage: [
+              'url(/home/projects/foo/assets/firefox.png),',
+              'url(/home/projects/foo/assets/bubbles.png),',
+              'linear-gradient(to right, rgba(30, 75, 115, 1), rgba(255, 255, 255, 0))',
+            ].join(' '),
+          },
+        },
+      ),
+    ).toEqual({
+      root: {
+        backgroundImage: [
+          'url(griffel:assets/firefox.png),',
+          'url(griffel:assets/bubbles.png),',
+          'linear-gradient(to right, rgba(30, 75, 115, 1), rgba(255, 255, 255, 0))',
+        ].join(' '),
+      },
+    });
+  });
+
+  it('handles keyframe arrays', () => {
+    expect(
+      normalizeStyleRules(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        {
+          root: {
+            animationName: [{ from: { height: '20px' }, to: { height: '10px' } }],
+          },
+        },
+      ),
+    ).toEqual({
+      root: {
+        animationName: [{ from: { height: '20px' }, to: { height: '10px' } }],
+      },
+    });
+  });
+});
diff --git a/packages/tag-processor/src/assets/relativePathToImportLike.test.ts b/packages/tag-processor/src/assets/relativePathToImportLike.test.ts
new file mode 100644
index 0000000..95f28d9
--- /dev/null
+++ b/packages/tag-processor/src/assets/relativePathToImportLike.test.ts
@@ -0,0 +1,74 @@
+import * as path from 'path';
+import { relativePathToImportLike } from './relativePathToImportLike';
+
+describe('relativePathToImportLike', () => {
+  it('handles POSIX paths', () => {
+    expect(
+      relativePathToImportLike(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        'assets/image.png',
+      ),
+    ).toBe('../../assets/image.png');
+
+    expect(
+      relativePathToImportLike(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/src/styles/Component.styles.ts',
+        },
+        'src/styles/Component.png',
+      ),
+    ).toBe('./Component.png');
+
+    expect(
+      relativePathToImportLike(
+        path.posix,
+        {
+          root: '/home/projects/foo',
+          filename: '/home/projects/foo/packages/components/src/index.styles.ts',
+        },
+        'packages/components/src/images/Component.png',
+      ),
+    ).toBe('./images/Component.png');
+  });
+
+  it('handles Windows paths', () => {
+    expect(
+      relativePathToImportLike(
+        path.win32,
+        {
+          root: 'C:\\Users\\Foo\\projects',
+          filename: 'C:\\Users\\Foo\\projects\\src\\styles\\Component.styles.ts',
+        },
+        'assets/image.png',
+      ),
+    ).toBe('../../assets/image.png');
+
+    expect(
+      relativePathToImportLike(
+        path.win32,
+        {
+          root: 'C:\\Users\\Foo\\projects',
+          filename: 'C:\\Users\\Foo\\projects\\src\\styles\\Component.styles.ts',
+        },
+        'src/styles/Component.png',
+      ),
+    ).toBe('./Component.png');
+
+    expect(
+      relativePathToImportLike(
+        path.win32,
+        {
+          root: 'C:\\Users\\Foo\\projects',
+          filename: 'C:\\Users\\Foo\\projects\\packages\\components\\src\\index.styles.ts',
+        },
+        'packages/components/src/images/Component.png',
+      ),
+    ).toBe('./images/Component.png');
+  });
+});
diff --git a/packages/tag-processor/src/utils/dedupeCSSRules.test.ts b/packages/tag-processor/src/utils/dedupeCSSRules.test.ts
new file mode 100644
index 0000000..6ccb026
--- /dev/null
+++ b/packages/tag-processor/src/utils/dedupeCSSRules.test.ts
@@ -0,0 +1,37 @@
+import type { CSSRulesByBucket } from '@griffel/core';
+import { dedupeCSSRules } from './dedupeCSSRules';
+
+describe('dedupeCSSRules', () => {
+  it('dedupes simple rules', () => {
+    const rules: CSSRulesByBucket = {
+      d: ['.foo { color: red; }', '.foo { color: red; }', '.baz { color: pink }'],
+    };
+
+    expect(dedupeCSSRules(rules)).toMatchObject({
+      d: ['.foo { color: red; }', '.baz { color: pink }'],
+    });
+  });
+
+  it('dedupes rules with metadata', () => {
+    const rules: CSSRulesByBucket = {
+      m: [
+        ['@media (min-width: 480px) { .foo { color: red; } }', { m: '(min-width: 480px)' }],
+        ['@media (min-width: 480px) { .baz { color: pink; } }', { m: '(min-width: 480px)' }],
+
+        ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }],
+        ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }],
+        ['@media (min-width: 600px) { .baz { color: pink; } }', { m: '(min-width: 600px)' }],
+      ],
+    };
+
+    expect(dedupeCSSRules(rules)).toMatchObject({
+      m: [
+        ['@media (min-width: 480px) { .foo { color: red; } }', { m: '(min-width: 480px)' }],
+        ['@media (min-width: 480px) { .baz { color: pink; } }', { m: '(min-width: 480px)' }],
+
+        ['@media (min-width: 600px) { .foo { color: red; } }', { m: '(min-width: 600px)' }],
+        ['@media (min-width: 600px) { .baz { color: pink; } }', { m: '(min-width: 600px)' }],
+      ],
+    });
+  });
+});