Skip to content

Preliminary JavaScript based CSS parser research

Christopher Joel edited this page Jan 10, 2016 · 3 revisions

Proposed goals of this project

  • Potential to be used in conjunction with Polymer or Polymer Designer. This one is fairly straight forward, but it should be kept in mind that any incompatibility here is a problem.

  • Parse CSS loosely. When parsing CSS, Polymer does not require a complete or tediously comprehensive AST - there are specific nodes that Polymer cares about transforming, and the contents of those nodes do not need to be parsed to any rigorous level of detail.

  • Parse CSS quickly. All CSS parsing will eat into Polymer or Designer's startup cost.

  • Graceful error recovery. If this parser is to be used in the context of an IDE, or as part of a runtime transformation for a framework, it should probably prefer recovery over rigid error reporting.

  • Browser compatibility. Ultimately, we need to support (until further notice) IE10 and Safari 8.

If any of these goals seem incorrect, or if any goals are missing, please suggest amendments to this list!

Existing solutions

I have tested the following libraries for compatibility against a smoke test that is included in the PR for shady-css-parser.

Here is a list of parser options, along with uncompressed and gzip'd file sizes:

Parser Uncompressed Gzip'd
shady-css-parser 16K 3.4K
ReworkCSS 11K 2.7K
CSSNext ??? ???
PostCSS 281K 57K
mensch 31K 7.6K

For the purposes of science, the candidates will be tested against a smoke test that can be found here.

ReworkCSS

ReworkCSS seems like an obviously good solution. The description reads:

CSS manipulations built on css, allowing you to automate vendor prefixing, create your own properties, inline images, anything you can imagine!

Sounds promising. Let's see how it works. The test looks like this:

var rework = require('./rework-css');
var fs = require('fs');
var css = fs.readFileSync('./test.css').toString();

var parsed = rework(css);

console.log(JSON.stringify(parsed, null, 2));

Test outcome

$ node ./test.js
/Users/cdata/repositories/google/funsies/parser-test/rework-css.js:72
      throw err;
      ^

Error: undefined:28:3: missing '}'
    at error (/Users/cdata/repositories/google/funsies/parser-test/rework-css.js:62:15)
    at declarations (/Users/cdata/repositories/google/funsies/parser-test/rework-css.js:259:26)
    at rule (/Users/cdata/repositories/google/funsies/parser-test/rework-css.js:560:21)
    at rules (/Users/cdata/repositories/google/funsies/parser-test/rework-css.js:117:70)
    at stylesheet (/Users/cdata/repositories/google/funsies/parser-test/rework-css.js:81:21)
    at module.exports (/Users/cdata/repositories/google/funsies/parser-test/rework-css.js:564:20)
    at Object.<anonymous> (/Users/cdata/repositories/google/funsies/parser-test/test.js:5:14)
    at Module._compile (module.js:434:26)
    at Object.Module._extensions..js (module.js:452:10)
    at Module.load (module.js:355:32)

Analysis

ReworkCSS, without any help, chokes when it hits @apply(--some-mixin); on line 28 of test.css.

Fair enough. In the world of the CSS grammar, @apply is quite an anomaly. The CSS spec does not afford for anything close to an @ rule within a ruleset:

ident   -?{nmstart}{nmchar}*
property
  : IDENT S*
  ;
declaration
  : property ':' S* expr prio?
  ;
ruleset
  : selector [ ',' S* selector ]*
    '{' S* declaration? [ ';' S* declaration? ]* '}' S*
  ;

ReworkCSS dutifully parses through all of the declarations it recognizes, and when it gets to @apply it thinks that there are no more declarations, so it tries to match the closing brace. When it finds unexpected content, it throws.

Extensibility

ReworkCSS is intended to be an extensible baseline parser, so I briefly considered extending the parser. ReworkCSS's extensibility comes via a use method that is implemented by another module. use looks like this:

Rework.prototype.use = function(fn){
  fn(this.obj.stylesheet, this);
  return this;
};

Unfortunately, since use operates on an already-parsed AST, it won't help us help ReworkCSS understand @apply. I tested ReworkCSS against custom properties and custom property mixins as well. The former parsed, the later did not.

Patch feasibility

It is hypothetically possible for us to submit a patch to make ReworkCSS understand ShadyCSS concepts. Such a patch would require that we broaden the definition of a declaration (possibly in violation of the CSS spec). It would probably also be necessary for us to patch ReworkCSS to facilitate a more graceful recovery from errors.

CSSNext

CSSNext seems like another good option. From the description:

cssnext is a CSS transpiler that allows you to use the latest CSS syntax today. It transforms new CSS specs into more compatible CSS so you don't need to wait for browser support.

I'm already a little worried, since I know that some of the syntax we are using is either not spec'd or non-standard. However, we continue in the interest of science.

Unfortunately, I was unable to build a browser-side library for CSSNext. It's probably possible with enough tinkering. However, in my efforts, I had to manicure the package.json due to many breaking devDependencies. When I finally got the (fairly esoteric) webpack build working, it consistently failed to build the "production" version of the library. The main repository claims tests are passing in Windows, and it's certainly possible that I am experiencing some kind of local environment problem. Until I get this resolved, I cannot report relevant file sizes for this library.

Thankfully, the npm package without devDependencies seemed to install just fine. The test looks like this:

var cssnext = require('cssnext');
var fs = require('fs');
var css = fs.readFileSync('./test.css').toString();

var parsed = cssnext(css);

console.log(JSON.stringify(parsed, null, 2));

Test outcome

$ node ./test.js

/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/lazy-result.js:167
        if (this.error) throw this.error;
                        ^
CssSyntaxError: <css input>:53:16: Missed semicolon
    at Input.error (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/input.js:65:21)
    at Parser.checkMissedSemicolon (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/parser.js:440:30)
    at Parser.decl (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/parser.js:277:18)
    at Parser.word (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/parser.js:136:30)
    at Parser.loop (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/parser.js:61:26)
    at Object.parse [as default] (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/parse.js:21:12)
    at new LazyResult (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/lazy-result.js:54:42)
    at Processor.process (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/node_modules/postcss/lib/processor.js:30:16)
    at cssnext (/Users/cdata/repositories/google/funsies/parser-test/node_modules/cssnext/dist/index.js:152:26)
    at Object.<anonymous> (/Users/cdata/repositories/google/funsies/parser-test/test.js:5:14)

Analysis

Interestingly, CSSNext didn't choke on our "ShadyCSS" syntax. Instead, it got scared when it encountered a normal property declaration with a missing semicolon at the end. The culprit is the font-family declaration on this line:

/*
 * #pathological.comment:foo ***/
div.interesting > .combinator:not(:nth-of-type(0)) {
  font-family: 'Missing Semicolon'
  position: relative;
}

Sadly, I was unable to find a configuration to tell CSSNext to not throw under this condition.

During the process of trimming down the test to discover if there was some subset ShadyCSS that CSSNext recognized, I discovered that CSSNext actually outputs full transformations (and not an AST). It looks like an extension to the dependency library PostCSS may be a more appropriate approach.

PostCSS

PostCSS is the kernel that makes CSSNext tick. The description is about as promising as any have been so far:

PostCSS is a tool for transforming styles with JS plugins. These plugins can support variables and mixins, transpile future CSS syntax, inline images, and more.

Happily, PostCSS has a very well kept dependency tree and build script. I was able to npm install and gulp. There are tests, and they all look green. So far so good.

Slightly less happily, PostCSS does not appear to be intended for browser use. Not only is it a large base library, but adding dependencies (without any plugins) when browserifying makes the library notably larger than others I have considered.

I want to put it to the test. Here is the test I ended up with:

var postcss = require('postcss');
var fs = require('fs');
var css = fs.readFileSync('./test.css').toString();

postcss().process(css).then(function(result) {
  console.log(JSON.stringify(result, null, 2));
}).catch(function(error) {
  console.error(error);
});

Test outcome

$ node ./test.js
{ [CssSyntaxError: <css input>:53:16: Missed semicolon]
  name: 'CssSyntaxError',
  reason: 'Missed semicolon',
# Really long source echoing omitted

Analysis

By default, PostCSS suffers from the same problem that CSSNext suffered from. This makes a lot of sense, since CSSNext is really just a package of plugins for PostCSS.

Extensibility

In exploring PostCSS's plugin architecture, I believe it is feasible to adapt the built-in parser with an inherited implementation that would allow the test to pass eventually.

An extending plug-in is able to patch the core Parser class and substitute discrete grammar parsing implementation. Here is an example of such a modified Parser: https://github.com/postcss/postcss-safe-parser/blob/master/lib/safe-parser.es6.

This design makes PostCSS a feasible candidate. As long as all grammatical hooks are easy to override, the sky is virtually the limit since any arbitrary parser could be implemented within PostCSS itself.

Mensch

Mensch is a CSS parser that we found early on while drudging up existing CSS parsers to potentially use in Designer. From the README, Mensch is:

A decent CSS parser.

I can appreciate the humility after the hyperbole of some other readme files I've read today. Let's see how it does. Here is the test:

var mensch = require('mensch');
var fs = require('fs');
var css = fs.readFileSync('./test.css').toString();

var parsed = mensch.parse(css);

console.log(JSON.stringify(parsed, null, 2));

Test outcome

Mensch yielded a very nice AST while gracefully recovering from error conditions.

Analysis

While Mensch successfully parsed the entire stylesheet, it incorrectly parsed some of the "ShadyCSS" syntax.

The custom property was parsed correctly:

{
  "type": "property",
  "name": "--some-var",
  "value": "red"
}

And at first glance, so was the custom property mixin:

{
  "type": "property",
  "name": "--some-mixin",
  "value": "color: red"
}

However, upon encountering the closing brace of the mixin, Mensch must have assumed that it had found the end of the current ruleset. The semicolon for the first mixin bleeds into the property name of the next mixin, and the mixin is also parsed as a new selector:

{
  "type": "rule",
  "selectors": [
    ";--another-mixin:"
  ],
  "declarations": [
    {
      "type": "property",
      "name": "color",
      "value": "blue"
    }
  ]
}

Happily, Mensch appears to be okay with properties that have only a value and no name, so it safely parsed @apply as a declaration:

{
  "type": "rule",
  "selectors": [
    "p.mixed-in"
  ],
  "declarations": [
    {
      "type": "property",
      "value": "@apply(--some-mixin)"
    }
  ]
}

Extensibility

Mensch does not support any kind of extensibility via plugins.

Patch feasability

Mensch's implementation is fairly small and does not depend on external libraries. Any design questions aside, it would be feasible to patch Mensch to support ShadyCSS constructs.