Repository: microsoft/griffel (ts)

Issue:
babel-preset: remove URL quotes can produce invalid result for inline SVG data URL
When working on #234 I didn't think it's possible for URLs to contain quotation marks, except:

```css
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='32' aria-hidden='true' viewBox='0 0 16 16' width='32' data-view-component='true' class='octicon octicon-mark-github v-align-middle'%3E%3Cpath fill-rule='evenodd' d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z'%3E%3C/path%3E%3C/svg%3E")
```

This is a valid rule that shows GitHub logo in background (example: https://codepen.io/yume-chan/pen/mdLYjpO)

However, if the leading and trailing quotation marks are removed, it becomes invalid.

Pull request description:
fix: do not always normalize quotes in url()
Fixes #256.

_I still would like to normalize URLs, but didn't find about a good way to do that._

This PR removes quotes normalization for URLs that are not pointing to assets. For assets we can keep it as it just URL will be just a file path.

Patch:
diff --git a/change/@griffel-babel-preset-005f6c96-ccea-4fc1-b641-b97b741f7b2a.json b/change/@griffel-babel-preset-005f6c96-ccea-4fc1-b641-b97b741f7b2a.json
new file mode 100644
index 0000000..1f80d2d
--- /dev/null
+++ b/change/@griffel-babel-preset-005f6c96-ccea-4fc1-b641-b97b741f7b2a.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "fix: do not always normalize quotes in url()",
+  "packageName": "@griffel/babel-preset",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/change/@griffel-webpack-extraction-plugin-46602e0c-3b1b-4553-a919-32df4dda9fd9.json b/change/@griffel-webpack-extraction-plugin-46602e0c-3b1b-4553-a919-32df4dda9fd9.json
new file mode 100644
index 0000000..9756a23
--- /dev/null
+++ b/change/@griffel-webpack-extraction-plugin-46602e0c-3b1b-4553-a919-32df4dda9fd9.json
@@ -0,0 +1,7 @@
+{
+  "type": "patch",
+  "comment": "fix: avoid empty CSS imports",
+  "packageName": "@griffel/webpack-extraction-plugin",
+  "email": "olfedias@microsoft.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/babel-preset/__fixtures__/assets-urls/code.ts b/packages/babel-preset/__fixtures__/assets-urls/code.ts
index f3924e2..9c207d6 100644
--- a/packages/babel-preset/__fixtures__/assets-urls/code.ts
+++ b/packages/babel-preset/__fixtures__/assets-urls/code.ts
@@ -5,5 +5,9 @@ export const useStyles = makeStyles({
   httpsUrl: { backgroundImage: 'url(https://www.example.com)' },
   httpsUrlWithQuotes: { backgroundImage: 'url("https://www.example.com")' },
   dataUrl: { backgroundImage: 'url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)' },
+  dataUrlQuotes: {
+    backgroundImage:
+      'url(\'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" height="32" aria-hidden="true" viewBox="0 0 16 16" width="32" data-view-component="true" class="octicon octicon-mark-github v-align-middle"%3E%3Cpath fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"%3E%3C/path%3E%3C/svg%3E\')',
+  },
   hashOnly: { filter: 'url(#a)' },
 });
diff --git a/packages/babel-preset/__fixtures__/assets-urls/output.ts b/packages/babel-preset/__fixtures__/assets-urls/output.ts
index 02c8de6..488b43a 100644
--- a/packages/babel-preset/__fixtures__/assets-urls/output.ts
+++ b/packages/babel-preset/__fixtures__/assets-urls/output.ts
@@ -8,11 +8,14 @@ export const useStyles = __styles(
       Bcmaq0h: 'fvpuk3z',
     },
     httpsUrlWithQuotes: {
-      Bcmaq0h: 'fvpuk3z',
+      Bcmaq0h: 'fcrzfig',
     },
     dataUrl: {
       Bcmaq0h: 'f1b0d1k8',
     },
+    dataUrlQuotes: {
+      Bcmaq0h: 'f15gfcnl',
+    },
     hashOnly: {
       Bhu2qc9: 'f1bk4c0y',
     },
@@ -21,7 +24,9 @@ export const useStyles = __styles(
     d: [
       `.f405sdg{background-image:url(http://www.example.com);}`,
       `.fvpuk3z{background-image:url(https://www.example.com);}`,
+      `.fcrzfig{background-image:url("https://www.example.com");}`,
       `.f1b0d1k8{background-image:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);}`,
+      `.f15gfcnl{background-image:url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" height="32" aria-hidden="true" viewBox="0 0 16 16" width="32" data-view-component="true" class="octicon octicon-mark-github v-align-middle"%3E%3Cpath fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"%3E%3C/path%3E%3C/svg%3E');}`,
       `.f1bk4c0y{-webkit-filter:url(#a);filter:url(#a);}`,
     ],
   },
diff --git a/packages/babel-preset/src/assets/isAssetUrl.ts b/packages/babel-preset/src/assets/isAssetUrl.ts
index 72021c3..165ab36 100644
--- a/packages/babel-preset/src/assets/isAssetUrl.ts
+++ b/packages/babel-preset/src/assets/isAssetUrl.ts
@@ -1,9 +1,11 @@
 export function isAssetUrl(value: string): boolean {
-  return (
-    !value.startsWith('data:') &&
-    !value.startsWith('http:') &&
-    !value.startsWith('https:') &&
-    !value.startsWith('//') /* Urls without protocol (use the same protocol as current page) */ &&
-    !value.startsWith('#') /* Hash only urls like `filter: url(#id)` */
-  );
+  const url = value.replace(/^['|"]/, '');
+  const isRemoteUrl =
+    url.startsWith('data:') ||
+    url.startsWith('http:') ||
+    url.startsWith('https:') ||
+    url.startsWith('//') /* Urls without protocol (use the same protocol as current page) */ ||
+    url.startsWith('#');
+
+  return !isRemoteUrl;
 }
diff --git a/packages/babel-preset/src/assets/normalizeStyleRules.ts b/packages/babel-preset/src/assets/normalizeStyleRules.ts
index 1667a8f..c56fd8d 100644
--- a/packages/babel-preset/src/assets/normalizeStyleRules.ts
+++ b/packages/babel-preset/src/assets/normalizeStyleRules.ts
@@ -26,12 +26,17 @@ export function normalizeStyleRule(
 
   return tokenize(ruleValue).reduce((result, token, index, array) => {
     if (token === 'url') {
-      // Quotes in URL are optional, so we can also normalize them
-      // https://www.w3.org/TR/CSS2/syndata.html#value-def-uri
-      const url = array[index + 1].replace(/['|"](.+)['|"]/, '$1').slice(1, -1);
+      const url = array[index + 1].slice(1, -1);
 
       if (isAssetUrl(url)) {
-        array[index + 1] = `(${normalizeAssetPath(path, projectRoot, filename, url)})`;
+        array[index + 1] = `(${normalizeAssetPath(
+          path,
+          projectRoot,
+          filename,
+          // 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})`;
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/missing-calls/code.ts b/packages/webpack-extraction-plugin/__fixtures__/webpack/missing-calls/code.ts
new file mode 100644
index 0000000..bb5adbd
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/missing-calls/code.ts
@@ -0,0 +1,1 @@
+export const foo = '__styles';
diff --git a/packages/webpack-extraction-plugin/__fixtures__/webpack/missing-calls/output.ts b/packages/webpack-extraction-plugin/__fixtures__/webpack/missing-calls/output.ts
new file mode 100644
index 0000000..bb5adbd
--- /dev/null
+++ b/packages/webpack-extraction-plugin/__fixtures__/webpack/missing-calls/output.ts
@@ -0,0 +1,1 @@
+export const foo = '__styles';
diff --git a/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts b/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts
index a2a8240..f25014e 100644
--- a/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts
+++ b/packages/webpack-extraction-plugin/src/babelPluginStripGriffelRuntime.ts
@@ -1,6 +1,6 @@
 import { NodePath, PluginObj, PluginPass, types as t } from '@babel/core';
 import { declare } from '@babel/helper-plugin-utils';
-import { CSSRulesByBucket } from '@griffel/core';
+import type { CSSRulesByBucket } from '@griffel/core';
 import * as path from 'path';
 
 type StripRuntimeBabelPluginOptions = {
@@ -15,7 +15,7 @@ type StripRuntimeBabelPluginState = PluginPass & {
 };
 
 export type StripRuntimeBabelPluginMetadata = {
-  cssRulesByBucket: CSSRulesByBucket;
+  cssRulesByBucket?: CSSRulesByBucket;
 };
 
 export function transformUrl(filename: string, resourceDirectory: string, assetPath: string) {
@@ -30,6 +30,16 @@ export function transformUrl(filename: string, resourceDirectory: string, assetP
   return relativeAssetPath.split(path.sep).join(path.posix.sep);
 }
 
+function concatCSSRulesByBucket(bucketA: CSSRulesByBucket = {}, bucketB: CSSRulesByBucket) {
+  Object.entries(bucketB).forEach(([cssBucketName, cssBucketEntries]) => {
+    bucketA[cssBucketName as keyof CSSRulesByBucket] = cssBucketEntries.concat(
+      bucketA[cssBucketName as keyof CSSRulesByBucket] || [],
+    );
+  });
+
+  return bucketA;
+}
+
 export const babelPluginStripGriffelRuntime = declare<
   Partial<StripRuntimeBabelPluginOptions>,
   PluginObj<StripRuntimeBabelPluginState>
@@ -38,11 +48,8 @@ export const babelPluginStripGriffelRuntime = declare<
 
   return {
     name: '@griffel/webpack-extraction-plugin/babel',
-    pre() {
-      this.cssRulesByBucket = {};
-    },
     post() {
-      (this.file.metadata as unknown as StripRuntimeBabelPluginMetadata).cssRulesByBucket = this.cssRulesByBucket!;
+      (this.file.metadata as unknown as StripRuntimeBabelPluginMetadata).cssRulesByBucket = this.cssRulesByBucket;
     },
 
     visitor: {
@@ -215,15 +222,11 @@ export const babelPluginStripGriffelRuntime = declare<
               if (functionKind === '__styles') {
                 const cssRulesByBucket = evaluationResult.value as CSSRulesByBucket;
 
-                Object.entries(cssRulesByBucket).forEach(([cssBucketName, cssBucketEntries]) => {
-                  state.cssRulesByBucket![cssBucketName as keyof CSSRulesByBucket] = cssBucketEntries.concat(
-                    state.cssRulesByBucket![cssBucketName as keyof CSSRulesByBucket] || [],
-                  );
-                });
+                state.cssRulesByBucket = concatCSSRulesByBucket(state.cssRulesByBucket, cssRulesByBucket);
               } else if (functionKind === '__resetStyles') {
                 const cssRules = evaluationResult.value as NonNullable<CSSRulesByBucket['r']>;
 
-                state.cssRulesByBucket!.r = cssRules.concat(state.cssRulesByBucket!.r || []);
+                state.cssRulesByBucket = concatCSSRulesByBucket(state.cssRulesByBucket, { r: cssRules });
               }
 
               argumentPath.remove();


Test patch:
diff --git a/packages/babel-preset/src/assets/isAssetUrl.test.ts b/packages/babel-preset/src/assets/isAssetUrl.test.ts
new file mode 100644
index 0000000..4747ded
--- /dev/null
+++ b/packages/babel-preset/src/assets/isAssetUrl.test.ts
@@ -0,0 +1,24 @@
+import { isAssetUrl } from './isAssetUrl';
+
+describe('isAssetUrl', () => {
+  it('handles remote URLs', () => {
+    expect(isAssetUrl('data://example.com')).toBe(false);
+    expect(isAssetUrl('http://example.com')).toBe(false);
+    expect(isAssetUrl('https://example.com')).toBe(false);
+    expect(isAssetUrl('//example.com')).toBe(false);
+    expect(isAssetUrl('#svg-part')).toBe(false);
+  });
+
+  it('handles assets URLs', () => {
+    expect(isAssetUrl('../file.jpg')).toBe(true);
+    expect(isAssetUrl('./file.jpg')).toBe(true);
+  });
+
+  it('handles quotes around URLs', () => {
+    expect(isAssetUrl('"//example.com"')).toBe(false);
+    expect(isAssetUrl("'//example.com'")).toBe(false);
+
+    expect(isAssetUrl('"./file.jpg"')).toBe(true);
+    expect(isAssetUrl("'./file.jpg'")).toBe(true);
+  });
+});
diff --git a/packages/webpack-extraction-plugin/src/webpackLoader.test.ts b/packages/webpack-extraction-plugin/src/webpackLoader.test.ts
index 8744ce7..8201d1a 100644
--- a/packages/webpack-extraction-plugin/src/webpackLoader.test.ts
+++ b/packages/webpack-extraction-plugin/src/webpackLoader.test.ts
@@ -190,4 +190,7 @@ describe('webpackLoader', () => {
   testFixture('assets');
   testFixture('assets-multiple');
   testFixture('reset-assets');
+
+  // Ensures that a file without __styles calls remains unprocessed
+  testFixture('missing-calls');
 });