Repository: macbre/analyze-css (js)
Issue: Error on @import rule with url containing semicolon
Hi @macbre,

I get an error message when trying to analyze the following css file.

style.css:
```
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap');

body {
}
```

How to reproduce:
```
$ analyze-css --file style.css 
Error: 600&display=swap');

body is an invalid expression
```

Here is the log.
```
$ DEBUG=analyze-css* analyze-css --file style.css
analyze-css:bin analyze-css v0.13.0
analyze-css:bin argv ["/Users/hashimoto/.nodebrew/node/v14.14.0/bin/node","/Users/hashimoto/.nodebrew/current/bin/analyze-css","--file","style.css"]
analyze-css:bin opts {"offenders":true,"file":"style.css"}
analyze-css:bin runner opts: {"file":"style.css","ignoreSslErrors":false,"noOffenders":false}
analyze-css:runner Loading local CSS file: /Users/hashimoto/Desktop/error/style.css
analyze-css:preprocessors Initializing...
analyze-css opts: {"noOffenders":false,"preprocessor":false,"file":"/Users/hashimoto/Desktop/error/style.css"}
analyze-css:rules Rules to be loaded: base64, bodySelectors, childSelectors, colors, comments, complex, duplicated, emptyRules, expressions, ieFixes, import, important, length, mediaQueries, minified, multiClassesSelectors, parsingErrors, prefixes, propertyResets, qualified, specificity, stats
analyze-css:rules "base64" loaded: Reports on base64-encoded images
analyze-css:rules "bodySelectors" loaded: Reports redundant body selectors
analyze-css:rules "childSelectors" loaded: Reports redundant child nodes selectors
analyze-css:rules "colors" loaded: Reports number of unique colors used in CSS
analyze-css:rules "comments" loaded: Reports too long CSS comments
analyze-css:rules "complex" loaded: Reports too complex CSS selectors
analyze-css:rules "duplicated" loaded: Reports duplicated CSS selectors and properties
analyze-css:rules "emptyRules" loaded: Total number of empty CSS rules
analyze-css:rules "expressions" loaded: Reports CSS expressions
analyze-css:rules "ieFixes" loaded: Reports fixes for old versions of Internet Explorer (IE9 and below)
analyze-css:rules "import" loaded: Number of @import rules
analyze-css:rules "important" loaded: Number of properties with value forced by !important
analyze-css:rules "length" loaded: Length of CSS file
analyze-css:rules "mediaQueries" loaded: Reports media queries
analyze-css:rules "minified" loaded: Reports not minified CSS 
analyze-css:rules "multiClassesSelectors" loaded: Reports selectors with multiple classes
analyze-css:rules "parsingErrors" loaded: CSS parsing errors
analyze-css:prefixes Using data generated on Using autoprefixer v10.2.5
analyze-css:rules "prefixes" loaded: Reports outdated vendor prefixes
analyze-css:rules "propertyResets" loaded: Reports accidental property resets
analyze-css:rules "qualified" loaded: Reports qualified selectors
analyze-css:rules "specificity" loaded: Reports rules specificity
analyze-css:rules "stats" loaded: Emit CSS stats
analyze-css:parser Going to parse 0.10 kB of CSS
analyze-css:parser CSS parsed
analyze-css {"type":"import","import":"url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400","position":{"start":{"line":1,"column":1},"end":{"line":1,"column":75}}}
analyze-css {"type":"rule","selectors":["600&display=swap');\n\nbody"],"declarations":[],"position":{"start":{"line":1,"column":75},"end":{"line":4,"column":2}}}
analyze-css:bin Exiting with exit code #1
Error: 600&display=swap');

body is an invalid expression
```
Pull request description: Fix handling of URLs with semicolons
Resolves #280 // see https://github.com/reworkcss/css/issues/137

----

```
$ echo "@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap'); body {}" | DEBUG=* ./bin/analyze-css.js -
  analyze-css:bin analyze-css v0.13.0 +0ms
...
  analyze-css:parser Going to parse 0.10 kB of CSS +0ms
  analyze-css:parser CSS parsed +1ms
  analyze-css {"type":"import","import":"url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400","position":{"start":{"line":1,"column":1},"end":{"line":1,"column":75}}} +17ms
  analyze-css {"type":"rule","selectors":["600&display=swap'); body"],"declarations":[],"position":{"start":{"line":1,"column":75},"end":{"line":1,"column":102}}} +0ms
  analyze-css:bin Exiting with exit code #1 +21ms
Error: 600&display=swap'); body is an invalid expression
```

Patch:
diff --git a/lib/index.js b/lib/index.js
index 2d28d0d..0381a6d 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -172,6 +172,15 @@ analyzer.prototype = {
     }, this);
   },
 
+  fixCss: function (css) {
+    // properly handle ; in @import URLs
+    // see https://github.com/macbre/analyze-css/pull/322
+    // see https://github.com/reworkcss/css/issues/137
+    return css.replace(/@import url([^)]+["'])/, (match) => {
+      return match.replace(/;/g, "%3B");
+    });
+  },
+
   parseCss: function (css) {
     var debug = require("debug")("analyze-css:parser");
     debug("Going to parse %s kB of CSS", (css.length / 1024).toFixed(2));
@@ -180,6 +189,8 @@ analyzer.prototype = {
       return this.error("Empty CSS was provided", analyzer.EXIT_EMPTY_CSS);
     }
 
+    css = this.fixCss(css);
+
     this.tree = cssParser(css, {
       // errors are listed in the parsingErrors property instead of being thrown (#84)
       silent: true,
@@ -190,6 +201,8 @@ analyzer.prototype = {
   },
 
   parseRules: function (rules) {
+    const debug = require("debug")("analyze-css:parseRules");
+
     rules.forEach(function (rule) {
       debug("%j", rule);
 
@@ -301,7 +314,9 @@ analyzer.prototype = {
 
         // {"type":"import","import":"url('/css/styles.css')"}
         case "import":
-          this.emit("import", rule.import);
+          // replace encoded semicolon back into ;
+          // https://github.com/macbre/analyze-css/pull/322
+          this.emit("import", rule.import.replace(/%3B/g, ";"));
           break;
       }
     }, this);


Test patch:
diff --git a/test/fixes/280-url-with-semicolons.test.js b/test/fixes/280-url-with-semicolons.test.js
new file mode 100644
index 0000000..87f4267
--- /dev/null
+++ b/test/fixes/280-url-with-semicolons.test.js
@@ -0,0 +1,22 @@
+const { describe, it } = require("@jest/globals");
+const assert = require('assert');
+
+describe('@import rule with url containing semicolon', () => {
+	it('is properly parsed', () => {
+        const analyzer = require('../..'),
+            css = `
+@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;800&display=swap');
+
+body {
+
+}
+            `.trim();
+
+        new analyzer(css, (err, res) => {
+            assert.ok(err === null, err);
+            assert.strictEqual(res.metrics.imports, 1, '@import is detected');
+            assert.strictEqual(res.metrics.selectors, 1, 'body {} is detected');
+            assert.deepStrictEqual(res.offenders.imports[0].message, "url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;800&display=swap')");
+        });
+	});
+});