├── .gitignore ├── .npmignore ├── index.js ├── Makefile ├── .jshintrc ├── lib ├── debug.js ├── stringify.js ├── parser.js └── lexer.js ├── test ├── escapes.js ├── real-world.js ├── fixtures │ ├── escapes.css │ └── tests.js ├── api.js ├── position.js ├── comments.js └── syntax.js ├── package.json ├── .github └── workflows │ └── ci.yaml ├── LICENSE ├── tools ├── reformat.js └── benchmark.js ├── HISTORY.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .jshintrc 3 | .npmignore 4 | .travis.yml 5 | Makefile 6 | test/ 7 | tools/ 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | lex : require('./lib/lexer'), 3 | parse: require('./lib/parser'), 4 | stringify: require('./lib/stringify') 5 | }; 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: lint test 2 | 3 | bench: 4 | @node tools/benchmark 5 | 6 | lint: 7 | @./node_modules/.bin/jshint --show-non-errors --config .jshintrc **/*.js 8 | 9 | test: 10 | @./node_modules/.bin/mocha --reporter spec 11 | 12 | .PHONY: test 13 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "boss": true, // Suppress warnings like `while (a = fn())` 3 | "expr": true, // Suppress warnings like `a || (a = 1)` 4 | "node": true, // Allow Node globals 5 | 6 | "globals": { 7 | // Mocha 8 | "describe": false, 9 | "it": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/debug.js: -------------------------------------------------------------------------------- 1 | exports = module.exports = debug; 2 | 3 | function debug(label) { 4 | return _debug.bind(null, label); 5 | } 6 | 7 | function _debug(label) { 8 | var args = [].slice.call(arguments, 1); 9 | args.unshift('[' + label + ']'); 10 | process.stderr.write(args.join(' ') + '\n'); 11 | } -------------------------------------------------------------------------------- /test/escapes.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | 5 | var mensch = require('..'); 6 | 7 | // http://mathiasbynens.be/notes/css-escapes 8 | describe('CSS Escape Sequences', function () { 9 | it('should be supported', function () { 10 | var file = path.join(__dirname, 'fixtures', 'escapes.css'); 11 | var css = fs.readFileSync(file, 'utf-8').replace(/\r\n/g, '\n'); 12 | 13 | var ast = mensch.parse(css, {comments: true}); 14 | var out = mensch.stringify(ast, {comments: true, compress: true}).replace(/\r\n/g, '\n'); 15 | 16 | assert.equal(out, css); 17 | }); 18 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mensch", 3 | "description": "A decent CSS parser", 4 | "version": "0.3.4", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "make test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/brettstimmerman/mensch.git" 12 | }, 13 | "homepage": "https://github.com/brettstimmerman/mensch", 14 | "keywords": [ 15 | "css", 16 | "parser", 17 | "parsing", 18 | "stylesheet" 19 | ], 20 | "devDependencies": { 21 | "jshint": "*", 22 | "mocha": "*" 23 | }, 24 | "author": "Brett Stimmerman ", 25 | "license": "MIT" 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [12.x, 14.x, 15.x, 16.x] 15 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm install 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Brett Stimmerman 2 | 3 | This software is released under the MIT license: 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the 'Software'), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tools/reformat.js: -------------------------------------------------------------------------------- 1 | /* 2 | Attempt to make minified CSS more test-friendly by reformatting it a bit. 3 | The idea is to replicated mensch output so that a test like the following is 4 | possible: 5 | 6 | css === mensch.stringify(mensch.parse(css)); 7 | 8 | Example: 9 | 10 | $ node reformat.js example.com.css 11 | => example.com.reformat.css 12 | */ 13 | 14 | var fs = require('fs'); 15 | var path = require('path'); 16 | 17 | var file = path.join(__dirname, process.argv[2]); 18 | 19 | var src = fs.readFileSync(file, 'utf8'); 20 | 21 | fs.writeFileSync(file.replace('.css', '.reformat.css'), 22 | src 23 | // Inject missing semi-colons. 24 | .replace(/([^;])\s*\}/gm, '$1;}') 25 | 26 | // Fix up whitespace around braces and semi-colons. 27 | .replace(/;(?!base64)\s*/g, ';\n') 28 | .replace(/\s*\{(?!\})\s*/g, ' {\n') 29 | .replace(/([^\n])\}/g, '$1\n}') 30 | .replace(/\}()/g, '}\n$1') 31 | 32 | // Fix up whitespace between properties and values. 33 | .replace(/:(?!\s+|image|hover|visited|link|focus|after|before|last-child|first-child|nth-child)/g, ': ') 34 | 35 | // Undo whitespace after http: because no lookbehind. 36 | .replace(/http: /g, 'http:') 37 | 38 | // Remove lines containing only a semi-colon. 39 | .replace(/^\s*;\s*$/gm, '') 40 | 41 | // Strip leading whitespace. 42 | .replace(/^\s+(\S)/gm, '$1') 43 | 44 | // Remove whitespace from empty rules. 45 | .replace(/\{\s+\}/gm, '{}') 46 | ); 47 | -------------------------------------------------------------------------------- /tools/benchmark.js: -------------------------------------------------------------------------------- 1 | // Benchmark lexing, parsing and stringifying about 1.5 kb of real-world CSS. 2 | // Borrowed from TJ Holowaychuk: 3 | // https://github.com/visionmedia/css/blob/master/benchmark.js 4 | 5 | var fs = require('fs'); 6 | var path = require('path'); 7 | 8 | var mensch = require('..'); 9 | var fixturePath = path.join(__dirname, '..', 'test', 'fixtures'); 10 | 11 | function read(file) { 12 | return fs.readFileSync(path.join(fixturePath, file), 'utf-8'); 13 | } 14 | 15 | var str = [ 16 | read('cnn.com.css'), 17 | read('espn.com.css'), 18 | read('plus.google.com.css'), 19 | read('twitter.com.css'), 20 | read('yahoo.com.css') 21 | ].join('\n'); 22 | 23 | var n = 500; 24 | var ops = 50; 25 | var t = process.hrtime(); 26 | var results = []; 27 | 28 | while (n--) { 29 | mensch.stringify(mensch.parse(str)); 30 | 31 | if (n % ops === 0) { 32 | t = process.hrtime(t); 33 | 34 | var ms = t[1] / 1000 / 1000; 35 | var persec = (ops * (1000 / ms) | 0); 36 | 37 | results.push(persec); 38 | process.stdout.write('\r [' + persec + ' ops/s] [' + n + ']'); 39 | t = process.hrtime(); 40 | } 41 | } 42 | 43 | function sum(arr) { 44 | return arr.reduce(function(sum, n) { 45 | return sum + n; 46 | }); 47 | } 48 | 49 | function mean(arr) { 50 | return sum(arr) / (arr.length || 0); 51 | } 52 | 53 | console.log(); 54 | console.log(' avg: %d ops/s', mean(results)); 55 | console.log(' size: %d kb', (str.length / 1024).toFixed(2)); 56 | -------------------------------------------------------------------------------- /test/real-world.js: -------------------------------------------------------------------------------- 1 | var DEBUG = true; 2 | 3 | var assert = require('assert'); 4 | var mensch = require('..'); 5 | 6 | describe('Real world CSS', function () { 7 | this.timeout(5000); 8 | var fs = require('fs'); 9 | var path = require('path'); 10 | 11 | ['cnn', 'espn', /*'plus.google',*/ 'twitter', 'yahoo'].forEach(function (name) { 12 | var file = path.join(__dirname, 'fixtures', name + '.com.css'); 13 | var css = fs.readFileSync(file, 'utf-8').trim().replace(/\r\n/g, '\n'); 14 | var size = (css.length / 1024).toFixed(); 15 | 16 | it(name + '.com [' + size + ' kb]', function () { 17 | var ast = mensch.parse(css, {comments: true}); 18 | var out = mensch.stringify(ast, {comments: true}).replace(/\r\n/g, '\n'); 19 | 20 | DEBUG && debug(css, out); 21 | 22 | assert.strictEqual(out, css, 'Result does not match input (' + name + ')'); 23 | }); 24 | }); 25 | }); 26 | 27 | function debug(css, out) { 28 | var line = 1; 29 | css.split('').some(function (c, i) { 30 | var equal = (c === out[i]); 31 | 32 | if (c === '\n') { line++; } 33 | 34 | if (!equal) { 35 | process.stderr.write('Line ' + line + '\n'); 36 | process.stderr.write('========\n'); 37 | process.stderr.write(JSON.stringify(css.slice(i - 6, i + 6)) + '\n'); 38 | process.stderr.write('--------\n'); 39 | process.stderr.write(JSON.stringify(out.slice(i - 6, i + 6)) + '\n'); 40 | process.stderr.write('========\n'); 41 | } 42 | 43 | return !equal; 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # mensch history 2 | 3 | ## 0.3.4 - 09 Nov 2019 4 | 5 | - Fix global leak and stringify indentation 6 | 7 | ## 0.3.3 - 04 Aug 2016 8 | 9 | - Added Bower.json 10 | - Preserve property spacing when value starts with "special/unexpected" chars '(@*/{):'. 11 | 12 | ## 0.3.2 - 18 Aug 2015 13 | 14 | - Fixed column/line computation for comments and at-rules (Fix #15) 15 | - Closing } and ; now takes precedence over declaration value parsing (Fix #14) 16 | - Ignore curly braces in strings (Fix #13) 17 | - Keep \n and \t inside values and consider them "whitespace" (Fix #12) 18 | - Fixed column count in positions for rows after the first line (Fix #18) 19 | - Enabled running test suite under Windows (CRLF vs LF issues) 20 | 21 | ## 0.3.1 - 1 Dec 2013 22 | 23 | - Retain whitespace in selectors. Closes #8 24 | - Add support for `@-ms-keyframes`. 25 | 26 | ## 0.3.0 - 23 Nov 2013 27 | 28 | - Improve handling of quotes in values. Closes #5 29 | - Add support for `@document` (and `@-moz-document`), `@namespace` and `@page`. 30 | 31 | ## 0.2.1 - 20 Sep 2013 32 | 33 | - Trim whitespace around grouped selectors. 34 | 35 | ## 0.2.0 - 18 Sep 2013 36 | 37 | - Correctly handle comments as children of at-groups. Fix #2 38 | 39 | ## 0.1.0 - 17 Jun 2013 40 | 41 | - Added new boolean `position` option to `parse()`, which will include position 42 | data in the AST when enabled. 43 | - Moved node.selector to node.selectors, and changed the value to an array. 44 | - Various parser improvements and bug fixes. 45 | 46 | ## 0.0.1 - 11 Jun 2013 47 | 48 | - Initial release. 49 | -------------------------------------------------------------------------------- /test/fixtures/escapes.css: -------------------------------------------------------------------------------- 1 | /* tests compressed for easy testing */ 2 | /* http://mathiasbynens.be/notes/css-escapes */ 3 | /* will match elements with class=":`(" */ 4 | .\3A \`\({} 5 | /* will match elements with class="1a2b3c" */ 6 | .\31 a2b3c{} 7 | /* will match the element with id="#fake-id" */ 8 | #\#fake-id{} 9 | /* will match the element with id="---" */ 10 | #\---{} 11 | /* will match the element with id="-a-b-c-" */ 12 | #-a-b-c-{} 13 | /* will match the element with id="©" */ 14 | #©{} 15 | /* More tests from http://mathiasbynens.be/demo/html5-id */ 16 | html{font:1.2em/1.6 Arial;} 17 | code{font-family:Consolas;} 18 | li code{background:rgba(255, 255, 255, .5);padding:.3em;} 19 | li{background:orange;} 20 | #♥{background:lime;} 21 | #©{background:lime;} 22 | #“‘’”{background:lime;} 23 | #☺☃{background:lime;} 24 | #⌘⌥{background:lime;} 25 | #𝄞♪♩♫♬{background:lime;} 26 | #\?{background:lime;} 27 | #\@{background:lime;} 28 | #\.{background:lime;} 29 | #\3A \){background:lime;} 30 | #\3A \`\({background:lime;} 31 | #\31 23{background:lime;} 32 | #\31 a2b3c{background:lime;} 33 | #\{background:lime;} 34 | #\<\>\<\<\<\>\>\<\>{background:lime;} 35 | #\+\+\+\+\+\+\+\+\+\+\[\>\+\+\+\+\+\+\+\>\+\+\+\+\+\+\+\+\+\+\>\+\+\+\>\+\<\<\<\<\-\]\>\+\+\.\>\+\.\+\+\+\+\+\+\+\.\.\+\+\+\.\>\+\+\.\<\<\+\+\+\+\+\+\+\+\+\+\+\+\+\+\+\.\>\.\+\+\+\.\-\-\-\-\-\-\.\-\-\-\-\-\-\-\-\.\>\+\.\>\.{background:lime;} 36 | #\#{background:lime;} 37 | #\#\#{background:lime;} 38 | #\#\.\#\.\#{background:lime;} 39 | #\_{background:lime;} 40 | #\{\}{background:lime;} 41 | #\.fake\-class{background:lime;} 42 | #foo\.bar{background:lime;} 43 | #\3A hover{background:lime;} 44 | #\3A hover\3A focus\3A active{background:lime;} 45 | #\[attr\=value\]{background:lime;} 46 | #f\/o\/o{background:lime;} 47 | #f\\o\\o{background:lime;} 48 | #f\*o\*o{background:lime;} 49 | #f\!o\!o{background:lime;} 50 | #f\'o\'o{background:lime;} 51 | #f\~o\~o{background:lime;} 52 | #f\+o\+o{background:lime;} -------------------------------------------------------------------------------- /test/api.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var mensch = require('..'); 3 | 4 | function testLex(name) { 5 | var data = tests[name]; 6 | 7 | it(name, function () { 8 | var tokens = mensch.lex(data.css); 9 | assert.deepEqual(tokens, data.lex); 10 | }); 11 | } 12 | 13 | function testParse(name) { 14 | var data = tests[name]; 15 | 16 | it(name, function () { 17 | data.parse.forEach(function (parse) { 18 | var ast = mensch.parse(parse.css || data.css, parse.options); 19 | assert.deepEqual(ast, parse.expect); 20 | }); 21 | }); 22 | } 23 | 24 | function testStringify(name) { 25 | var data = tests[name]; 26 | var options = data.stringify && data.stringify.options; 27 | 28 | it(name, function () { 29 | var css = mensch.stringify(mensch.parse(data.css, options), options); 30 | assert.equal(fixup(css), data.css); 31 | }); 32 | } 33 | 34 | function fixup(css) { 35 | return css.replace(/\n+/g, ' ').replace(/[ ]{2,}/g, '').trim(); 36 | } 37 | 38 | // ----------------------------------------------------------------------------- 39 | 40 | var tests = require('./fixtures/tests'); 41 | var testNames = Object.keys(tests); 42 | 43 | describe('API', function () { 44 | describe('.lex(css)', function () { 45 | testNames.forEach(testLex); 46 | }); 47 | 48 | describe('.parse(css)', function () { 49 | testNames.forEach(testParse); 50 | }); 51 | 52 | describe('.stringify(ast)', function () { 53 | testNames.forEach(testStringify); 54 | }); 55 | 56 | describe('.stringify(ast, {compress: true}', function () { 57 | it('should compress whitespace', function () { 58 | var css = [ 59 | 'body {', 60 | ' color:black;', 61 | ' font-weight:bold;', 62 | '}' 63 | ].join('\n'); 64 | 65 | var ast = mensch.parse(css); 66 | var expect = css.replace(/\s/g, ''); 67 | 68 | assert.equal(mensch.stringify(ast, {compress: true}), expect); 69 | }); 70 | }); 71 | 72 | describe('.stringify(ast, {indent: \' \'})', function () { 73 | it('should indent two spaces', function () { 74 | var css = [ 75 | 'body {', 76 | ' color: black;', 77 | ' font-weight: bold;', 78 | '}' 79 | ].join('\n'); 80 | 81 | var ast = mensch.parse(css); 82 | var out = mensch.stringify(ast, {indentation: ' '}); 83 | 84 | assert.equal(out, css); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/position.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var mensch = require('..'); 3 | 4 | describe('Parse with options.position=true', function () { 5 | describe('with comments', function () { 6 | it('should count comment length', function() { 7 | var css = 'body p {}'; 8 | var ast = mensch.parse(css, { comment: true, position: true }); 9 | assert.deepEqual(ast.stylesheet.rules[0].position, { start: { line: 1, col: 1 }, end: { line: 1, col: 8 } }); 10 | 11 | css = 'body /* comment */p {}'; 12 | ast = mensch.parse(css, { comment: true, position: true }); 13 | assert.deepEqual(ast.stylesheet.rules[0].position, { start: { line: 1, col: 1 }, end: { line: 1, col: 21 } }); 14 | 15 | css = 'body /* multiline \n comment */p {}'; 16 | ast = mensch.parse(css, { comment: true, position: true }); 17 | assert.deepEqual(ast.stylesheet.rules[0].position, { start: { line: 1, col: 1 }, end: { line: 2, col: 14 } }); 18 | }); 19 | }); 20 | describe('with atrules', function () { 21 | it('should count rule length', function() { 22 | var css = '@media screen {}'; 23 | var ast = mensch.parse(css, { comment: true, position: true }); 24 | assert.deepEqual(ast.stylesheet.rules[0].position, { start: { line: 1, col: 1 }, end: { line: 1, col: 15 } }); 25 | }); 26 | }); 27 | describe('positions should be consistents between lines', function() { 28 | it('should have same column numbering in first and second line', function() { 29 | var css = 'selector { prop: val }\nselector { prop: val }'; 30 | var ast = mensch.parse(css, { comment: true, position: true }); 31 | assert.deepEqual(ast.stylesheet.rules[0].position, { start: { line: 1, col: 1 }, end: { line: 1, col: 10 } }); 32 | assert.deepEqual(ast.stylesheet.rules[1].position, { start: { line: 2, col: 1 }, end: { line: 2, col: 10 } }); 33 | }); 34 | it('should have same column numbering in first and second line and for selector/properties', function() { 35 | var css = 'selector {\nprop: val;\n}\nselector {\nprop: val;\n}'; 36 | var ast = mensch.parse(css, { comment: true, position: true }); 37 | assert.deepEqual(ast.stylesheet.rules[0].position, { start: { line: 1, col: 1 }, end: { line: 1, col: 10 } }); 38 | assert.deepEqual(ast.stylesheet.rules[0].declarations[0].position, { start: { line: 2, col: 1 }, end: { line: 2, col: 10 } }); 39 | assert.deepEqual(ast.stylesheet.rules[1].position, { start: { line: 4, col: 1 }, end: { line: 4, col: 10 } }); 40 | assert.deepEqual(ast.stylesheet.rules[1].declarations[0].position, { start: { line: 5, col: 1 }, end: { line: 5, col: 10 } }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mensch [![Node.js CI](https://github.com/brettstimmerman/mensch/workflows/Node.js%20CI/badge.svg)](https://github.com/brettstimmerman/mensch/actions/workflows/ci.yaml) 2 | 3 | A decent CSS parser. 4 | 5 | # usage 6 | 7 | ```sh 8 | npm install mensch 9 | ``` 10 | 11 | ```js 12 | var mensch = require('mensch'); 13 | 14 | var ast = mensch.parse('p { color: black; }'); 15 | var css = mensch.stringify(ast); 16 | 17 | console.log(css); 18 | // => p { color: black; } 19 | ``` 20 | 21 | # api 22 | 23 | ## parse(css, [options={}]) 24 | 25 | Convert a CSS string or an array of lexical tokens into a `stringify`-able AST. 26 | 27 | - `css` {String|Array} CSS string or array of lexical tokens 28 | - `[options]` {Object} 29 | - `[options.comments=false]` {Boolean} Allow comment nodes in the AST. 30 | - `[options.position=false]` {Boolean} Allow line/column position in the AST. 31 | 32 | When `{position: true}`, AST node will have a `position` property: 33 | 34 | ```js 35 | { 36 | type: 'comment', 37 | text: ' Hello World! ', 38 | position: { 39 | start: { line: 1, col: 1 }, 40 | end: { line 1, col: 18 } 41 | } 42 | } 43 | ``` 44 | 45 | ## stringify(ast, [options={}]) 46 | 47 | Convert a `stringify`-able AST into a CSS string. 48 | 49 | - `ast` {Object} A `stringify`-able AST 50 | - `[options]` {Object} 51 | - `[options.comments=false]` {Boolean} Allow comments in the stringified CSS. 52 | - `[options.indentation='']` {String} E.g., `indentation: ' '` will indent by 53 | two spaces. 54 | 55 | ## lex(css) 56 | 57 | Convert a CSS string to an array of lexical tokens for use with `.parse()`. 58 | 59 | - `css` {String} CSS 60 | 61 | # non-validating 62 | 63 | Mensch is a non-validating CSS parser. While it can handle the major language 64 | constructs just fine, and it can recover from gaffes like mis-matched braces and 65 | missing or extraneous semi-colons, mensch can't tell you when it finds 66 | invalid CSS like a misspelled property name or a misplaced `@import`. 67 | 68 | # comments 69 | 70 | Unlike most CSS parsers, mensch allows comments to be represented in the AST and 71 | subsequently stringified with the `{comments: true}` option. 72 | 73 | ```js 74 | var options = { comments: true }; 75 | ``` 76 | 77 | ```js 78 | var ast = mensch.parse('.red { color: red; /* Natch. */ }', options); 79 | var css = mensch.stringify(ast, options); 80 | 81 | console.log(css); 82 | //=> .red { color: red; /* Natch. */ } 83 | ``` 84 | 85 | However, comments within the context of a selector, property, etc., will be 86 | ignored. These comments are difficult to represent in the AST. 87 | 88 | ```js 89 | var ast = mench.parse('.red /*1*/ { color /*2*/: /*3*/ red /*4*/; }', options); 90 | var css = mesch.stringify(ast, options); 91 | 92 | console.log(css); 93 | //=> .red { color: red; } 94 | ``` 95 | 96 | # ast 97 | 98 | The structure of mensch's AST riffs on several existing CSS parsers, but it 99 | might not be 100% compatible with other CSS parsers. Here it is in a nutshell: 100 | 101 | ```js 102 | { 103 | type: 'stylesheet' 104 | stylesheet: { 105 | rules: [{ 106 | type: 'rule', 107 | selectors: ['.foo'], 108 | declarations: [{ 109 | type: 'property', 110 | name: 'color', 111 | value: 'black' 112 | }] 113 | }] 114 | } 115 | } 116 | ``` 117 | 118 | # credits 119 | 120 | Mensch is based on several existing CSS parsers, but 121 | [nzakas/parser-lib](https://github.com/nzakas/parser-lib) and 122 | [visionmedia/css](https://github.com/visionmedia/css) are notable influences. 123 | 124 | # known users 125 | 126 | [voidlabs/mosaico](https://github.com/voidlabs/mosaico) uses Mensch parser to parse custom-flavored CSS rules in email templates and make the template editable: positions, comment parsing, multiple declarations for the same property have been keys to the choice of Mensch! 127 | 128 | [Automattic/juice](https://github.com/Automattic/juice) moved to Mensch CSS parser since 3.0 release in order to fix dozen of issues with the previous parser, expecially with support for "multiple properties declarations" in the same ruleset and with invalid values. 129 | 130 | Please let us know if you use Mensch in your library! 131 | -------------------------------------------------------------------------------- /test/comments.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var mensch = require('..'); 3 | 4 | describe('Comments', function () { 5 | var options = { comments: true }; 6 | 7 | describe('are supported when', function () { 8 | it('solo', function () { 9 | var css = '/* filibuster! */'; 10 | var ast = mensch.parse(css, options); 11 | 12 | assert.deepEqual(ast, { 13 | type: "stylesheet", 14 | stylesheet: { 15 | rules: [{ 16 | type: 'comment', 17 | text: ' filibuster! ' 18 | }] 19 | } 20 | }); 21 | }); 22 | 23 | it('before a rule', function () { 24 | var css = '/* filibuster! */ body { color: black; }'; 25 | var ast = mensch.parse(css, options); 26 | 27 | assert.deepEqual(ast, { 28 | type: "stylesheet", 29 | stylesheet: { 30 | rules: [{ 31 | type: 'comment', 32 | text: ' filibuster! ' 33 | }, { 34 | type: 'rule', 35 | selectors: ['body'], 36 | declarations: [{ 37 | type: 'property', 38 | name: 'color', 39 | value: 'black' 40 | }] 41 | }] 42 | } 43 | }); 44 | }); 45 | 46 | it('after a rule', function () { 47 | var css = 'body { color: black; } /* filibuster! */'; 48 | var ast = mensch.parse(css, options); 49 | 50 | assert.deepEqual(ast, { 51 | type: "stylesheet", 52 | stylesheet: { 53 | rules: [{ 54 | type: 'rule', 55 | selectors: ['body'], 56 | declarations: [{ 57 | type: 'property', 58 | name: 'color', 59 | value: 'black' 60 | }] 61 | }, { 62 | type: 'comment', 63 | text: ' filibuster! ' 64 | }] 65 | } 66 | }); 67 | }); 68 | 69 | it('before a declaration', function () { 70 | var css = 'body { /* filibuster! */ color: black; }'; 71 | var ast = mensch.parse(css, options); 72 | 73 | assert.deepEqual(ast, { 74 | type: "stylesheet", 75 | stylesheet: { 76 | rules: [{ 77 | type: 'rule', 78 | selectors: ['body'], 79 | declarations: [{ 80 | type: 'comment', 81 | text: ' filibuster! ' 82 | }, { 83 | type: 'property', 84 | name: 'color', 85 | value: 'black' 86 | }] 87 | }] 88 | } 89 | }); 90 | }); 91 | 92 | it('after a declaration', function () { 93 | var css = 'body { color: black; /* filibuster! */ }'; 94 | var ast = mensch.parse(css, options); 95 | 96 | assert.deepEqual(ast, { 97 | type: "stylesheet", 98 | stylesheet: { 99 | rules: [{ 100 | type: 'rule', 101 | selectors: ['body'], 102 | declarations: [{ 103 | type: 'property', 104 | name: 'color', 105 | value: 'black' 106 | }, { 107 | type: 'comment', 108 | text: ' filibuster! ' 109 | }] 110 | }] 111 | } 112 | }); 113 | }); 114 | 115 | it('inside an at-group', function () { 116 | var css = [ 117 | '@media (max-width: 1024) {', 118 | '/* boom! */', 119 | '', 120 | '.foo {', 121 | 'color: blue;', 122 | '}', 123 | '}' 124 | ].join('\n'); 125 | 126 | var ast = mensch.parse(css, options); 127 | 128 | assert.deepEqual(ast, { 129 | type: 'stylesheet', 130 | stylesheet: { 131 | rules: [{ 132 | type: 'media', 133 | name: '(max-width: 1024)', 134 | prefix: undefined, 135 | rules: [{ 136 | type: 'comment', 137 | text: ' boom! ' 138 | }, { 139 | type: 'rule', 140 | selectors: ['.foo'], 141 | declarations: [{ 142 | type: 'property', 143 | name: 'color', 144 | value: 'blue' 145 | }] 146 | }] 147 | }] 148 | } 149 | }); 150 | 151 | var out = mensch.stringify(ast, options); 152 | 153 | assert.equal(out, css); 154 | }); 155 | }); 156 | 157 | describe('are not supported when', function () { 158 | var expect = { 159 | type: "stylesheet", 160 | stylesheet: { 161 | rules: [{ 162 | type: 'rule', 163 | selectors: ['body'], 164 | declarations: [{ 165 | type: 'property', 166 | name: 'color', 167 | value: 'black' 168 | }] 169 | }] 170 | } 171 | }; 172 | 173 | var tests = { 174 | 'between a block and its identifier': 'body /* #sadtuba */ { color: black; }', 175 | 'after a property': 'body { color /* #sadtuba */: black; }', 176 | 'before a value': 'body { color: /* #sadtuba */ black; }', 177 | 'after a value': 'body { color: black /* #sadtuba */; }' 178 | }; 179 | 180 | function test(label, css) { 181 | it(label, function () { 182 | var ast = mensch.parse(css, options); 183 | assert.deepEqual(ast, expect); 184 | }); 185 | } 186 | 187 | Object.keys(tests).forEach(function (key) { 188 | test(key, tests[key]); 189 | }); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /lib/stringify.js: -------------------------------------------------------------------------------- 1 | var DEBUG = false; // `true` to print debugging info. 2 | var TIMER = false; // `true` to time calls to `stringify()` and print the results. 3 | 4 | var debug = require('./debug')('stringify'); 5 | 6 | var _comments; // Whether comments are allowed in the stringified CSS. 7 | var _compress; // Whether the stringified CSS should be compressed. 8 | var _indentation; // Indentation option value. 9 | var _level; // Current indentation level. 10 | var _n; // Compression-aware newline character. 11 | var _s; // Compression-aware space character. 12 | 13 | exports = module.exports = stringify; 14 | 15 | /** 16 | * Convert a `stringify`-able AST into a CSS string. 17 | * 18 | * @param {Object} `stringify`-able AST 19 | * @param {Object} [options] 20 | * @param {Boolean} [options.comments=false] allow comments in the CSS 21 | * @param {Boolean} [options.compress=false] compress whitespace 22 | * @param {String} [options.indentation=''] indentation sequence 23 | * @returns {String} CSS 24 | */ 25 | function stringify(ast, options) { 26 | var start; // Debug timer start. 27 | 28 | options || (options = {}); 29 | _indentation = options.indentation || ''; 30 | _compress = !!options.compress; 31 | _comments = !!options.comments; 32 | _level = 1; 33 | 34 | if (_compress) { 35 | _n = _s = ''; 36 | } else { 37 | _n = '\n'; 38 | _s = ' '; 39 | } 40 | 41 | TIMER && (start = Date.now()); 42 | 43 | var css = reduce(ast.stylesheet.rules, stringifyNode).join('\n').trim(); 44 | 45 | TIMER && debug('ran in', (Date.now() - start) + 'ms'); 46 | 47 | return css; 48 | } 49 | 50 | // -- Functions -------------------------------------------------------------- 51 | 52 | /** 53 | * Modify the indentation level, or return a compression-aware sequence of 54 | * spaces equal to the current indentation level. 55 | * 56 | * @param {Number} [level=undefined] indentation level modifier 57 | * @returns {String} sequence of spaces 58 | */ 59 | function indent(level) { 60 | if (level) { 61 | _level += level; 62 | return; 63 | } 64 | 65 | if (_compress) { return ''; } 66 | 67 | return Array(_level).join(_indentation || ''); 68 | } 69 | 70 | // -- Stringify Functions ------------------------------------------------------ 71 | 72 | /** 73 | * Stringify an @-rule AST node. 74 | * 75 | * Use `stringifyAtGroup()` when dealing with @-groups that may contain blocks 76 | * such as @media. 77 | * 78 | * @param {String} type @-rule type. E.g., import, charset 79 | * @returns {String} Stringified @-rule 80 | */ 81 | function stringifyAtRule(node) { 82 | return '@' + node.type + ' ' + node.value + ';' + _n; 83 | } 84 | 85 | /** 86 | * Stringify an @-group AST node. 87 | * 88 | * Use `stringifyAtRule()` when dealing with @-rules that may not contain blocks 89 | * such as @import. 90 | * 91 | * @param {Object} node @-group AST node 92 | * @returns {String} 93 | */ 94 | function stringifyAtGroup(node) { 95 | var label = ''; 96 | var prefix = node.prefix || ''; 97 | 98 | if (node.name) { 99 | label = ' ' + node.name; 100 | } 101 | 102 | // FIXME: @-rule conditional logic is leaking everywhere. 103 | var chomp = node.type !== 'page'; 104 | 105 | return '@' + prefix + node.type + label + _s + stringifyBlock(node, chomp) + _n; 106 | } 107 | 108 | /** 109 | * Stringify a comment AST node. 110 | * 111 | * @param {Object} node comment AST node 112 | * @returns {String} 113 | */ 114 | function stringifyComment(node) { 115 | if (!_comments) { return ''; } 116 | 117 | return '/*' + (node.text || '') + '*/' + _n; 118 | } 119 | 120 | /** 121 | * Stringify a rule AST node. 122 | * 123 | * @param {Object} node rule AST node 124 | * @returns {String} 125 | */ 126 | function stringifyRule(node) { 127 | var label; 128 | 129 | if (node.selectors) { 130 | label = node.selectors.join(',' + _n); 131 | } else { 132 | label = '@' + node.type; 133 | label += node.name ? ' ' + node.name : ''; 134 | } 135 | 136 | return indent() + label + _s + stringifyBlock(node) + _n; 137 | } 138 | 139 | 140 | // -- Stringify Helper Functions ----------------------------------------------- 141 | 142 | /** 143 | * Reduce an array by applying a function to each item and retaining the truthy 144 | * results. 145 | * 146 | * When `item.type` is `'comment'` `stringifyComment` will be applied instead. 147 | * 148 | * @param {Array} items array to reduce 149 | * @param {Function} fn function to call for each item in the array 150 | * @returns {Mixed} Truthy values will be retained, falsy values omitted 151 | * @returns {Array} retained results 152 | */ 153 | function reduce(items, fn) { 154 | return items.reduce(function (results, item) { 155 | var result = (item.type === 'comment') ? stringifyComment(item) : fn(item); 156 | result && results.push(result); 157 | return results; 158 | }, []); 159 | } 160 | 161 | /** 162 | * Stringify an AST node with the assumption that it represents a block of 163 | * declarations or other @-group contents. 164 | * 165 | * @param {Object} node AST node 166 | * @returns {String} 167 | */ 168 | // FIXME: chomp should not be a magic boolean parameter 169 | function stringifyBlock(node, chomp) { 170 | var children = node.declarations; 171 | var fn = stringifyDeclaration; 172 | 173 | if (node.rules) { 174 | children = node.rules; 175 | fn = stringifyRule; 176 | } 177 | 178 | children = stringifyChildren(children, fn); 179 | children && (children = _n + children + (chomp ? '' : _n)); 180 | 181 | return '{' + children + indent() + '}'; 182 | } 183 | 184 | /** 185 | * Stringify an array of child AST nodes by calling the given stringify function 186 | * once for each child, and concatenating the results. 187 | * 188 | * @param {Array} children `node.rules` or `node.declarations` 189 | * @param {Function} fn stringify function 190 | * @returns {String} 191 | */ 192 | function stringifyChildren(children, fn) { 193 | if (!children) { return ''; } 194 | 195 | indent(1); 196 | var results = reduce(children, fn); 197 | indent(-1); 198 | 199 | if (!results.length) { return ''; } 200 | 201 | return results.join(_n); 202 | } 203 | 204 | /** 205 | * Stringify a declaration AST node. 206 | * 207 | * @param {Object} node declaration AST node 208 | * @returns {String} 209 | */ 210 | function stringifyDeclaration(node) { 211 | if (node.type === 'property') { 212 | return stringifyProperty(node); 213 | } 214 | 215 | DEBUG && debug('stringifyDeclaration: unexpected node:', JSON.stringify(node)); 216 | } 217 | 218 | /** 219 | * Stringify an AST node. 220 | * 221 | * @param {Object} node AST node 222 | * @returns {String} 223 | */ 224 | function stringifyNode(node) { 225 | switch (node.type) { 226 | // Cases are listed in roughly descending order of probability. 227 | case 'rule': return stringifyRule(node); 228 | 229 | case 'media' : 230 | case 'keyframes': return stringifyAtGroup(node); 231 | 232 | case 'comment': return stringifyComment(node); 233 | 234 | case 'import' : 235 | case 'charset' : 236 | case 'namespace': return stringifyAtRule(node); 237 | 238 | case 'font-face': 239 | case 'supports' : 240 | case 'viewport' : 241 | case 'document' : 242 | case 'page' : return stringifyAtGroup(node); 243 | } 244 | 245 | DEBUG && debug('stringifyNode: unexpected node: ' + JSON.stringify(node)); 246 | } 247 | 248 | /** 249 | * Stringify an AST property node. 250 | * 251 | * @param {Object} node AST property node 252 | * @returns {String} 253 | */ 254 | function stringifyProperty(node) { 255 | var name = node.name ? node.name + ':' + _s : ''; 256 | 257 | return indent() + name + node.value + ';'; 258 | } 259 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | var DEBUG = false; // `true` to print debugging info. 2 | var TIMER = false; // `true` to time calls to `parse()` and print the results. 3 | 4 | var debug = require('./debug')('parse'); 5 | var lex = require('./lexer'); 6 | 7 | exports = module.exports = parse; 8 | 9 | var _comments; // Whether comments are allowed. 10 | var _depth; // Current block nesting depth. 11 | var _position; // Whether to include line/column position. 12 | var _tokens; // Array of lexical tokens. 13 | 14 | /** 15 | * Convert a CSS string or array of lexical tokens into a `stringify`-able AST. 16 | * 17 | * @param {String} css CSS string or array of lexical token 18 | * @param {Object} [options] 19 | * @param {Boolean} [options.comments=false] allow comment nodes in the AST 20 | * @returns {Object} `stringify`-able AST 21 | */ 22 | function parse(css, options) { 23 | var start; // Debug timer start. 24 | 25 | options || (options = {}); 26 | _comments = !!options.comments; 27 | _position = !!options.position; 28 | 29 | _depth = 0; 30 | 31 | // Operate on a copy of the given tokens, or the lex()'d CSS string. 32 | _tokens = Array.isArray(css) ? css.slice() : lex(css); 33 | 34 | var rule; 35 | var rules = []; 36 | var token; 37 | 38 | TIMER && (start = Date.now()); 39 | 40 | while ((token = next())) { 41 | rule = parseToken(token); 42 | rule && rules.push(rule); 43 | } 44 | 45 | TIMER && debug('ran in', (Date.now() - start) + 'ms'); 46 | 47 | return { 48 | type: "stylesheet", 49 | stylesheet: { 50 | rules: rules 51 | } 52 | }; 53 | } 54 | 55 | // -- Functions -------------------------------------------------------------- 56 | 57 | /** 58 | * Build an AST node from a lexical token. 59 | * 60 | * @param {Object} token lexical token 61 | * @param {Object} [override] object hash of properties that override those 62 | * already in the token, or that will be added to the token. 63 | * @returns {Object} AST node 64 | */ 65 | function astNode(token, override) { 66 | override || (override = {}); 67 | 68 | var key; 69 | var keys = ['type', 'name', 'value']; 70 | var node = {}; 71 | 72 | // Avoiding [].forEach for performance reasons. 73 | for (var i = 0; i < keys.length; ++i) { 74 | key = keys[i]; 75 | 76 | if (token[key]) { 77 | node[key] = override[key] || token[key]; 78 | } 79 | } 80 | 81 | keys = Object.keys(override); 82 | 83 | for (i = 0; i < keys.length; ++i) { 84 | key = keys[i]; 85 | 86 | if (!node[key]) { 87 | node[key] = override[key]; 88 | } 89 | } 90 | 91 | if (_position) { 92 | node.position = { 93 | start: token.start, 94 | end: token.end 95 | }; 96 | } 97 | 98 | DEBUG && debug('astNode:', JSON.stringify(node, null, 2)); 99 | 100 | return node; 101 | } 102 | 103 | /** 104 | * Remove a lexical token from the stack and return the removed token. 105 | * 106 | * @returns {Object} lexical token 107 | */ 108 | function next() { 109 | var token = _tokens.shift(); 110 | DEBUG && debug('next:', JSON.stringify(token, null, 2)); 111 | return token; 112 | } 113 | 114 | // -- Parse* Functions --------------------------------------------------------- 115 | 116 | /** 117 | * Convert an @-group lexical token to an AST node. 118 | * 119 | * @param {Object} token @-group lexical token 120 | * @returns {Object} @-group AST node 121 | */ 122 | function parseAtGroup(token) { 123 | _depth = _depth + 1; 124 | 125 | // As the @-group token is assembled, relevant token values are captured here 126 | // temporarily. They will later be used as `tokenize()` overrides. 127 | var overrides = {}; 128 | 129 | switch (token.type) { 130 | case 'font-face': 131 | case 'viewport' : 132 | overrides.declarations = parseDeclarations(); 133 | break; 134 | 135 | case 'page': 136 | overrides.prefix = token.prefix; 137 | overrides.declarations = parseDeclarations(); 138 | break; 139 | 140 | default: 141 | overrides.prefix = token.prefix; 142 | overrides.rules = parseRules(); 143 | } 144 | 145 | return astNode(token, overrides); 146 | } 147 | 148 | /** 149 | * Convert an @import lexical token to an AST node. 150 | * 151 | * @param {Object} token @import lexical token 152 | * @returns {Object} @import AST node 153 | */ 154 | function parseAtImport(token) { 155 | return astNode(token); 156 | } 157 | 158 | /** 159 | * Convert an @charset token to an AST node. 160 | * 161 | * @param {Object} token @charset lexical token 162 | * @returns {Object} @charset node 163 | */ 164 | function parseCharset(token) { 165 | return astNode(token); 166 | } 167 | 168 | /** 169 | * Convert a comment token to an AST Node. 170 | * 171 | * @param {Object} token comment lexical token 172 | * @returns {Object} comment node 173 | */ 174 | function parseComment(token) { 175 | return astNode(token, {text: token.text}); 176 | } 177 | 178 | function parseNamespace(token) { 179 | return astNode(token); 180 | } 181 | 182 | /** 183 | * Convert a property lexical token to a property AST node. 184 | * 185 | * @returns {Object} property node 186 | */ 187 | function parseProperty(token) { 188 | return astNode(token); 189 | } 190 | 191 | /** 192 | * Convert a selector lexical token to a selector AST node. 193 | * 194 | * @param {Object} token selector lexical token 195 | * @returns {Object} selector node 196 | */ 197 | function parseSelector(token) { 198 | function trim(str) { 199 | return str.trim(); 200 | } 201 | 202 | return astNode(token, { 203 | type: 'rule', 204 | selectors: token.text.split(',').map(trim), 205 | declarations: parseDeclarations(token) 206 | }); 207 | } 208 | 209 | /** 210 | * Convert a lexical token to an AST node. 211 | * 212 | * @returns {Object|undefined} AST node 213 | */ 214 | function parseToken(token) { 215 | switch (token.type) { 216 | // Cases are listed in roughly descending order of probability. 217 | case 'property': return parseProperty(token); 218 | 219 | case 'selector': return parseSelector(token); 220 | 221 | case 'at-group-end': _depth = _depth - 1; return; 222 | 223 | case 'media' : 224 | case 'keyframes' :return parseAtGroup(token); 225 | 226 | case 'comment': if (_comments) { return parseComment(token); } break; 227 | 228 | case 'charset': return parseCharset(token); 229 | case 'import': return parseAtImport(token); 230 | 231 | case 'namespace': return parseNamespace(token); 232 | 233 | case 'font-face': 234 | case 'supports' : 235 | case 'viewport' : 236 | case 'document' : 237 | case 'page' : return parseAtGroup(token); 238 | } 239 | 240 | DEBUG && debug('parseToken: unexpected token:', JSON.stringify(token)); 241 | } 242 | 243 | // -- Parse Helper Functions --------------------------------------------------- 244 | 245 | /** 246 | * Iteratively parses lexical tokens from the stack into AST nodes until a 247 | * conditional function returns `false`, at which point iteration terminates 248 | * and any AST nodes collected are returned. 249 | * 250 | * @param {Function} conditionFn 251 | * @param {Object} token the lexical token being parsed 252 | * @returns {Boolean} `true` if the token should be parsed, `false` otherwise 253 | * @return {Array} AST nodes 254 | */ 255 | function parseTokensWhile(conditionFn) { 256 | var node; 257 | var nodes = []; 258 | var token; 259 | 260 | while ((token = next()) && (conditionFn && conditionFn(token))) { 261 | node = parseToken(token); 262 | node && nodes.push(node); 263 | } 264 | 265 | // Place an unused non-`end` lexical token back onto the stack. 266 | if (token && token.type !== 'end') { 267 | _tokens.unshift(token); 268 | } 269 | 270 | return nodes; 271 | } 272 | 273 | /** 274 | * Convert a series of tokens into a sequence of declaration AST nodes. 275 | * 276 | * @returns {Array} declaration nodes 277 | */ 278 | function parseDeclarations() { 279 | return parseTokensWhile(function (token) { 280 | return (token.type === 'property' || token.type === 'comment'); 281 | }); 282 | } 283 | 284 | /** 285 | * Convert a series of tokens into a sequence of rule nodes. 286 | * 287 | * @returns {Array} rule nodes 288 | */ 289 | function parseRules() { 290 | return parseTokensWhile(function () { return _depth; }); 291 | } 292 | -------------------------------------------------------------------------------- /test/syntax.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var mensch = require('..'); 3 | 4 | function ensure(css, expect, options) { 5 | if (typeof expect != 'string') { 6 | options = expect; 7 | expect = null; 8 | } 9 | 10 | expect || (expect = css.trim()); 11 | options || (options = {}); 12 | 13 | var ast = mensch.parse(css, options); 14 | var out = mensch.stringify(ast, options).trim(); 15 | 16 | assert.equal(out, expect); 17 | } 18 | 19 | describe('General Syntax', function () { 20 | describe('strings in selectors, and braces in strings', function () { 21 | it('should work', function () { 22 | var css = [ 23 | 'abbr[title*="{"] {', 24 | 'color: black;', 25 | '}', 26 | '', 27 | 'abbr[title^="}"] {', 28 | 'color: red;', 29 | '}' 30 | ].join('\n'); 31 | 32 | ensure(css); 33 | }); 34 | }); 35 | 36 | describe('strings in values', function () { 37 | it('should work', function () { 38 | var css = [ 39 | '.sele {', 40 | 'content: "hel\\"lo";', 41 | '}' 42 | ].join('\n'); 43 | 44 | ensure(css); 45 | 46 | css = [ 47 | '.sele {', 48 | 'voice-family: "\\"}\\"";', 49 | 'voice-family: inherit;', 50 | '}' 51 | ].join('\n'); 52 | 53 | ensure(css); 54 | 55 | css = [ 56 | '.klass {', 57 | '/* " */', 58 | '/* \' */', 59 | '/* \\\' \\" \\*/', 60 | "content: '\"';", 61 | "content: '\\\"';", 62 | 'content: "\'";', 63 | 'content: "\\\'";', 64 | "content: '/* dude \\*/';", 65 | "content: '/* ';", 66 | "background: url('\\\"');", 67 | 'content: "du\\\nde";', 68 | '}' 69 | ].join('\n'); 70 | 71 | var ast = mensch.parse(css, {comments: true}); 72 | var out = mensch.stringify(ast, {comments: true}).trim(); 73 | 74 | // Normalize extra newlines for comparison. 75 | out = out.replace(/\n\n/g, '\n'); 76 | 77 | var expect = css.trim(); 78 | assert.equal(out, expect); 79 | 80 | css = [ 81 | '.onemore {', 82 | 'content: "\\\"";', 83 | 'content: "\\\'";', 84 | 'content: "{}";', 85 | '}' 86 | ].join('\n'); 87 | 88 | ensure(css); 89 | }); 90 | }); 91 | 92 | describe('unexpected braces and semi-colons', function () { 93 | it('should be ignored', function () { 94 | var css = [ 95 | 'body {', 96 | 'font-size: small;', 97 | '}', 98 | '}', // <- unexpected 99 | '', 100 | 'h2 {', 101 | '{', // <- unexpected 102 | 'color: red;;', // <- extra semi-colon 103 | '}' 104 | ].join('\n'); 105 | 106 | var expect = css 107 | .replace('{\n{', '{') 108 | .replace('}\n}', '}') 109 | .replace(';;', ';'); 110 | 111 | ensure(css, expect); 112 | }); 113 | }); 114 | 115 | describe('a missing semi-colon after an @-rule', function () { 116 | it('should be injected', function () { 117 | var css = [ 118 | '@import "foo.css" print', // <- missing semi-colon 119 | '', 120 | 'body {', 121 | 'color: black;', 122 | '}' 123 | ].join('\n'); 124 | 125 | var expect = css.replace('print', 'print;'); 126 | 127 | ensure(css, expect); 128 | 129 | css = [ 130 | '@charset "utf-8"', // <- missing semi-colon 131 | '', 132 | 'body {', 133 | 'color: black;', 134 | '}' 135 | ].join('\n'); 136 | 137 | expect = css.replace('8"', '8";'); 138 | 139 | ensure(css, expect); 140 | }); 141 | }); 142 | 143 | describe('a missing semi-colon after the final declaration in a block', function () { 144 | it('should be injected', function () { 145 | var css = [ 146 | 'body {', 147 | 'color: black;', 148 | 'font-weight: bold', // <- missing semi-colon 149 | '}' 150 | ].join('\n'); 151 | 152 | var expect = css.replace('bold', 'bold;'); 153 | 154 | ensure(css, expect); 155 | }); 156 | }); 157 | 158 | describe('deep nesting of @-groups', function () { 159 | it('is invalid, but should work', function () { 160 | var css = [ 161 | '@supports (display: border-box) {', 162 | '.foo {', 163 | 'color: red;', 164 | '}', 165 | '', 166 | '@media print {', 167 | '.foo {', 168 | 'color: black;', 169 | '}', 170 | '', 171 | '@supports (display: table) {', 172 | '.foo {', 173 | 'color: blue;', 174 | '}', 175 | '', 176 | '}', 177 | '', 178 | '}', 179 | '}' 180 | ].join('\n'); 181 | 182 | ensure(css); 183 | }); 184 | }); 185 | 186 | 187 | describe('pseudo-classes within @media blocks', function () { 188 | it('should work', function () { 189 | var css = [ 190 | '@media screen and (max-width: 700px) {', 191 | '.nav a {', 192 | 'display: block;', 193 | '}', 194 | '', 195 | '.nav a:hover {', 196 | 'text-decoration: none;', 197 | '}', 198 | '', 199 | '.nav a:focus {', 200 | 'text-decoration: underline;', 201 | '}', 202 | '}' 203 | ].join('\n'); 204 | 205 | ensure(css); 206 | }); 207 | }); 208 | 209 | describe('whitespace within selectors', function () { 210 | it('should be retained', function () { 211 | var css = [ 212 | 'body \t ', 213 | 'div \r ', 214 | 'span \f ', 215 | 'a {', 216 | 'color: red;', 217 | '}' 218 | ].join('\n'); 219 | 220 | ensure(css); 221 | }); 222 | }); 223 | 224 | describe('closing brackets in a declaration', function() { 225 | it('should take precedence over value parsing', function() { 226 | var css = [ 227 | 'body {background: ;}', 228 | '#yo {display: block;}' 229 | ].join('\n'); 230 | 231 | var expect = [ 232 | 'body {}', 233 | '', 234 | '#yo {', 235 | 'display: block;', 236 | '}' 237 | ].join('\n'); 238 | 239 | ensure(css, expect); 240 | }); 241 | 242 | it('should take precedence over declaration parsing', function() { 243 | var css = [ 244 | 'body {background:}', 245 | '#yo {display: block;}' 246 | ].join('\n'); 247 | 248 | var expect = [ 249 | 'body {}', 250 | '', 251 | '#yo {', 252 | 'display: block;', 253 | '}' 254 | ].join('\n'); 255 | 256 | ensure(css, expect); 257 | }); 258 | }); 259 | 260 | describe('semicolon in a declaration', function() { 261 | it('should take precedence over value expectation', function() { 262 | var css = [ 263 | 'body {background:;line-height:10px;}' 264 | ].join('\n'); 265 | 266 | var expect = [ 267 | 'body {', 268 | 'line-height: 10px;', 269 | '}' 270 | ].join('\n'); 271 | 272 | ensure(css, expect); 273 | }); 274 | }); 275 | 276 | describe('opening and closing brackets in comments', function() { 277 | it('should be ignored', function() { 278 | var css = [ 279 | '@media tty {', 280 | 'i {', 281 | 'color: /* } */ black;', 282 | '}', 283 | '}', 284 | '', 285 | 'a {', 286 | 'color: white;', 287 | '}' 288 | ].join('\n'); 289 | 290 | var expect = css.replace('/* } */ ', ''); 291 | 292 | ensure(css, expect); 293 | }); 294 | }); 295 | 296 | describe('opening and closing brackets in strings', function() { 297 | it('should be ignored', function() { 298 | var css = [ 299 | '@media tty {', 300 | 'i {', 301 | 'content: "\\";/*" "*/}} a { color: black; } /*";', 302 | '}', 303 | '}', 304 | '', 305 | 'a {', 306 | 'color: white;', 307 | '}' 308 | ].join('\n'); 309 | 310 | ensure(css); 311 | }); 312 | }); 313 | 314 | describe('lexer whitespace handing', function() { 315 | it('should keep tabs in values', function() { 316 | var css = [ 317 | 'a {', 318 | 'border-width: 2px 3px\t4px 5px;', 319 | '}' 320 | ].join('\n'); 321 | 322 | ensure(css); 323 | }); 324 | it('should keep newlines in values', function() { 325 | var css = [ 326 | 'a {', 327 | 'border-width: 2px 3px\n4px 5px;', 328 | '}' 329 | ].join('\n'); 330 | 331 | ensure(css); 332 | }); 333 | }); 334 | 335 | describe('when using weird chars in property values', function() { 336 | 337 | it('should keep internal spaces', function() { 338 | var css = [ 339 | 'body {', 340 | ' background-color:( yellow;', 341 | ' background-color:@ yellow;', 342 | ' background-color:* yellow;', 343 | ' background-color:/ yellow;', 344 | ' background-color:{ yellow;', 345 | ' background-color:) yellow;', 346 | ' background-color:: yellow;', 347 | '}' 348 | ].join('\n'); 349 | 350 | var expect = css.replace(/ background-color:/g,'background-color: '); 351 | 352 | ensure(css, expect); 353 | }); 354 | }); 355 | 356 | }); 357 | -------------------------------------------------------------------------------- /lib/lexer.js: -------------------------------------------------------------------------------- 1 | var DEBUG = false; // `true` to print debugging info. 2 | var TIMER = false; // `true` to time calls to `lex()` and print the results. 3 | 4 | var debug = require('./debug')('lex'); 5 | 6 | exports = module.exports = lex; 7 | 8 | /** 9 | * Convert a CSS string into an array of lexical tokens. 10 | * 11 | * @param {String} css CSS 12 | * @returns {Array} lexical tokens 13 | */ 14 | function lex(css) { 15 | var start; // Debug timer start. 16 | 17 | var buffer = ''; // Character accumulator 18 | var ch; // Current character 19 | var column = 0; // Current source column number 20 | var cursor = -1; // Current source cursor position 21 | var depth = 0; // Current nesting depth 22 | var line = 1; // Current source line number 23 | var state = 'before-selector'; // Current state 24 | var stack = [state]; // State stack 25 | var token = {}; // Current token 26 | var tokens = []; // Token accumulator 27 | 28 | // Supported @-rules, in roughly descending order of usage probability. 29 | var atRules = [ 30 | 'media', 31 | 'keyframes', 32 | { name: '-webkit-keyframes', type: 'keyframes', prefix: '-webkit-' }, 33 | { name: '-moz-keyframes', type: 'keyframes', prefix: '-moz-' }, 34 | { name: '-ms-keyframes', type: 'keyframes', prefix: '-ms-' }, 35 | { name: '-o-keyframes', type: 'keyframes', prefix: '-o-' }, 36 | 'font-face', 37 | { name: 'import', state: 'before-at-value' }, 38 | { name: 'charset', state: 'before-at-value' }, 39 | 'supports', 40 | 'viewport', 41 | { name: 'namespace', state: 'before-at-value' }, 42 | 'document', 43 | { name: '-moz-document', type: 'document', prefix: '-moz-' }, 44 | 'page' 45 | ]; 46 | 47 | // -- Functions ------------------------------------------------------------ 48 | 49 | /** 50 | * Advance the character cursor and return the next character. 51 | * 52 | * @returns {String} The next character. 53 | */ 54 | function getCh() { 55 | skip(); 56 | return css[cursor]; 57 | } 58 | 59 | /** 60 | * Return the state at the given index in the stack. 61 | * The stack is LIFO so indexing is from the right. 62 | * 63 | * @param {Number} [index=0] Index to return. 64 | * @returns {String} state 65 | */ 66 | function getState(index) { 67 | return index ? stack[stack.length - 1 - index] : state; 68 | } 69 | 70 | /** 71 | * Look ahead for a string beginning from the next position. The string 72 | * being looked for must start at the next position. 73 | * 74 | * @param {String} str The string to look for. 75 | * @returns {Boolean} Whether the string was found. 76 | */ 77 | function isNextString(str) { 78 | var start = cursor + 1; 79 | return (str === css.slice(start, start + str.length)); 80 | } 81 | 82 | /** 83 | * Find the start position of a substring beginning from the next 84 | * position. The string being looked for may begin anywhere. 85 | * 86 | * @param {String} str The substring to look for. 87 | * @returns {Number|false} The position, or `false` if not found. 88 | */ 89 | function find(str) { 90 | var pos = css.slice(cursor).indexOf(str); 91 | 92 | return pos > 0 ? pos : false; 93 | } 94 | 95 | /** 96 | * Determine whether a character is next. 97 | * 98 | * @param {String} ch Character. 99 | * @returns {Boolean} Whether the character is next. 100 | */ 101 | function isNextChar(ch) { 102 | return ch === peek(1); 103 | } 104 | 105 | /** 106 | * Return the character at the given cursor offset. The offset is relative 107 | * to the cursor, so negative values move backwards. 108 | * 109 | * @param {Number} [offset=1] Cursor offset. 110 | * @returns {String} Character. 111 | */ 112 | function peek(offset) { 113 | return css[cursor + (offset || 1)]; 114 | } 115 | 116 | /** 117 | * Remove the current state from the stack and set the new current state. 118 | * 119 | * @returns {String} The removed state. 120 | */ 121 | function popState() { 122 | var removed = stack.pop(); 123 | state = stack[stack.length - 1]; 124 | 125 | return removed; 126 | } 127 | 128 | /** 129 | * Set the current state and add it to the stack. 130 | * 131 | * @param {String} newState The new state. 132 | * @returns {Number} The new stack length. 133 | */ 134 | function pushState(newState) { 135 | state = newState; 136 | stack.push(state); 137 | 138 | return stack.length; 139 | } 140 | 141 | /** 142 | * Replace the current state with a new state. 143 | * 144 | * @param {String} newState The new state. 145 | * @returns {String} The replaced state. 146 | */ 147 | function replaceState(newState) { 148 | var previousState = state; 149 | stack[stack.length - 1] = state = newState; 150 | 151 | return previousState; 152 | } 153 | 154 | /** 155 | * Move the character cursor. Positive numbers move the cursor forward. 156 | * Negative numbers are not supported! 157 | * 158 | * @param {Number} [n=1] Number of characters to skip. 159 | */ 160 | function skip(n) { 161 | if ((n || 1) == 1) { 162 | if (css[cursor] == '\n') { 163 | line++; 164 | column = 1; 165 | } else { 166 | column++; 167 | } 168 | cursor++; 169 | } else { 170 | var skipStr = css.slice(cursor, cursor + n).split('\n'); 171 | if (skipStr.length > 1) { 172 | line += skipStr.length - 1; 173 | column = 1; 174 | } 175 | column += skipStr[skipStr.length - 1].length; 176 | cursor = cursor + n; 177 | } 178 | } 179 | 180 | /** 181 | * Add the current token to the pile and reset the buffer. 182 | */ 183 | function addToken() { 184 | token.end = { 185 | line: line, 186 | col: column 187 | }; 188 | 189 | DEBUG && debug('addToken:', JSON.stringify(token, null, 2)); 190 | 191 | tokens.push(token); 192 | 193 | buffer = ''; 194 | token = {}; 195 | } 196 | 197 | /** 198 | * Set the current token. 199 | * 200 | * @param {String} type Token type. 201 | */ 202 | function initializeToken(type) { 203 | token = { 204 | type: type, 205 | start: { 206 | line: line, 207 | col : column 208 | } 209 | }; 210 | } 211 | 212 | // -- Main Loop ------------------------------------------------------------ 213 | 214 | /* 215 | The main loop is a state machine that reads in one character at a time, 216 | and determines what to do based on the current state and character. 217 | This is implemented as a series of nested `switch` statements and the 218 | case orders have been mildly optimized based on rough probabilities 219 | calculated by processing a small sample of real-world CSS. 220 | 221 | Further optimization (such as a dispatch table) shouldn't be necessary 222 | since the total number of cases is very low. 223 | */ 224 | 225 | TIMER && (start = Date.now()); 226 | 227 | while (ch = getCh()) { 228 | DEBUG && debug(ch, getState()); 229 | 230 | // column += 1; 231 | 232 | switch (ch) { 233 | // Space 234 | case ' ': 235 | switch (getState()) { 236 | case 'selector': 237 | case 'value': 238 | case 'value-paren': 239 | case 'at-group': 240 | case 'at-value': 241 | case 'comment': 242 | case 'double-string': 243 | case 'single-string': 244 | buffer += ch; 245 | break; 246 | } 247 | break; 248 | 249 | // Newline or tab 250 | case '\n': 251 | case '\t': 252 | case '\r': 253 | case '\f': 254 | switch (getState()) { 255 | case 'value': 256 | case 'value-paren': 257 | case 'at-group': 258 | case 'comment': 259 | case 'single-string': 260 | case 'double-string': 261 | case 'selector': 262 | buffer += ch; 263 | break; 264 | 265 | case 'at-value': 266 | // Tokenize an @-rule if a semi-colon was omitted. 267 | if ('\n' === ch) { 268 | token.value = buffer.trim(); 269 | addToken(); 270 | popState(); 271 | } 272 | break; 273 | } 274 | 275 | // if ('\n' === ch) { 276 | // column = 0; 277 | // line += 1; 278 | // } 279 | break; 280 | 281 | case ':': 282 | switch (getState()) { 283 | case 'name': 284 | token.name = buffer.trim(); 285 | buffer = ''; 286 | 287 | replaceState('before-value'); 288 | break; 289 | 290 | case 'before-selector': 291 | buffer += ch; 292 | 293 | initializeToken('selector'); 294 | pushState('selector'); 295 | break; 296 | 297 | case 'before-value': 298 | replaceState('value'); 299 | buffer += ch; 300 | break; 301 | 302 | default: 303 | buffer += ch; 304 | break; 305 | } 306 | break; 307 | 308 | case ';': 309 | switch (getState()) { 310 | case 'name': 311 | case 'before-value': 312 | case 'value': 313 | // Tokenize a declaration 314 | // if value is empty skip the declaration 315 | if (buffer.trim().length > 0) { 316 | token.value = buffer.trim(), 317 | addToken(); 318 | } 319 | replaceState('before-name'); 320 | break; 321 | 322 | case 'value-paren': 323 | // Insignificant semi-colon 324 | buffer += ch; 325 | break; 326 | 327 | case 'at-value': 328 | // Tokenize an @-rule 329 | token.value = buffer.trim(); 330 | addToken(); 331 | popState(); 332 | break; 333 | 334 | case 'before-name': 335 | // Extraneous semi-colon 336 | break; 337 | 338 | default: 339 | buffer += ch; 340 | break; 341 | } 342 | break; 343 | 344 | case '{': 345 | switch (getState()) { 346 | case 'selector': 347 | // If the sequence is `\{` then assume that the brace should be escaped. 348 | if (peek(-1) === '\\') { 349 | buffer += ch; 350 | break; 351 | } 352 | 353 | // Tokenize a selector 354 | token.text = buffer.trim(); 355 | addToken(); 356 | replaceState('before-name'); 357 | depth = depth + 1; 358 | break; 359 | 360 | case 'at-group': 361 | // Tokenize an @-group 362 | token.name = buffer.trim(); 363 | 364 | // XXX: @-rules are starting to get hairy 365 | switch (token.type) { 366 | case 'font-face': 367 | case 'viewport' : 368 | case 'page' : 369 | pushState('before-name'); 370 | break; 371 | 372 | default: 373 | pushState('before-selector'); 374 | } 375 | 376 | addToken(); 377 | depth = depth + 1; 378 | break; 379 | 380 | case 'name': 381 | case 'at-rule': 382 | // Tokenize a declaration or an @-rule 383 | token.name = buffer.trim(); 384 | addToken(); 385 | pushState('before-name'); 386 | depth = depth + 1; 387 | break; 388 | 389 | case 'comment': 390 | case 'double-string': 391 | case 'single-string': 392 | // Ignore braces in comments and strings 393 | buffer += ch; 394 | break; 395 | case 'before-value': 396 | replaceState('value'); 397 | buffer += ch; 398 | break; 399 | } 400 | 401 | break; 402 | 403 | case '}': 404 | switch (getState()) { 405 | case 'before-name': 406 | case 'name': 407 | case 'before-value': 408 | case 'value': 409 | // If the buffer contains anything, it is a value 410 | if (buffer) { 411 | token.value = buffer.trim(); 412 | } 413 | 414 | // If the current token has a name and a value it should be tokenized. 415 | if (token.name && token.value) { 416 | addToken(); 417 | } 418 | 419 | // Leave the block 420 | initializeToken('end'); 421 | addToken(); 422 | popState(); 423 | 424 | // We might need to leave again. 425 | // XXX: What about 3 levels deep? 426 | if ('at-group' === getState()) { 427 | initializeToken('at-group-end'); 428 | addToken(); 429 | popState(); 430 | } 431 | 432 | if (depth > 0) { 433 | depth = depth - 1; 434 | } 435 | 436 | break; 437 | 438 | case 'at-group': 439 | case 'before-selector': 440 | case 'selector': 441 | // If the sequence is `\}` then assume that the brace should be escaped. 442 | if (peek(-1) === '\\') { 443 | buffer += ch; 444 | break; 445 | } 446 | 447 | if (depth > 0) { 448 | // Leave block if in an at-group 449 | if ('at-group' === getState(1)) { 450 | initializeToken('at-group-end'); 451 | addToken(); 452 | } 453 | } 454 | 455 | if (depth > 1) { 456 | popState(); 457 | } 458 | 459 | if (depth > 0) { 460 | depth = depth - 1; 461 | } 462 | break; 463 | 464 | case 'double-string': 465 | case 'single-string': 466 | case 'comment': 467 | // Ignore braces in comments and strings. 468 | buffer += ch; 469 | break; 470 | } 471 | 472 | break; 473 | 474 | // Strings 475 | case '"': 476 | case "'": 477 | switch (getState()) { 478 | case 'double-string': 479 | if ('"' === ch && '\\' !== peek(-1)) { 480 | popState(); 481 | } 482 | break; 483 | 484 | case 'single-string': 485 | if ("'" === ch && '\\' !== peek(-1)) { 486 | popState(); 487 | } 488 | break; 489 | 490 | case 'before-at-value': 491 | replaceState('at-value'); 492 | pushState('"' === ch ? 'double-string' : 'single-string'); 493 | break; 494 | 495 | case 'before-value': 496 | replaceState('value'); 497 | pushState('"' === ch ? 'double-string' : 'single-string'); 498 | break; 499 | 500 | case 'comment': 501 | // Ignore strings within comments. 502 | break; 503 | 504 | default: 505 | if ('\\' !== peek(-1)) { 506 | pushState('"' === ch ? 'double-string' : 'single-string'); 507 | } 508 | } 509 | 510 | buffer += ch; 511 | break; 512 | 513 | // Comments 514 | case '/': 515 | switch (getState()) { 516 | case 'comment': 517 | case 'double-string': 518 | case 'single-string': 519 | // Ignore 520 | buffer += ch; 521 | break; 522 | 523 | case 'before-value': 524 | case 'selector': 525 | case 'name': 526 | case 'value': 527 | if (isNextChar('*')) { 528 | // Ignore comments in selectors, properties and values. They are 529 | // difficult to represent in the AST. 530 | var pos = find('*/'); 531 | 532 | if (pos) { 533 | skip(pos + 1); 534 | } 535 | } else { 536 | if (getState() == 'before-value') replaceState('value'); 537 | buffer += ch; 538 | } 539 | break; 540 | 541 | default: 542 | if (isNextChar('*')) { 543 | // Create a comment token 544 | initializeToken('comment'); 545 | pushState('comment'); 546 | skip(); 547 | } 548 | else { 549 | buffer += ch; 550 | } 551 | break; 552 | } 553 | break; 554 | 555 | // Comment end or universal selector 556 | case '*': 557 | switch (getState()) { 558 | case 'comment': 559 | if (isNextChar('/')) { 560 | // Tokenize a comment 561 | token.text = buffer; // Don't trim()! 562 | skip(); 563 | addToken(); 564 | popState(); 565 | } 566 | else { 567 | buffer += ch; 568 | } 569 | break; 570 | 571 | case 'before-selector': 572 | buffer += ch; 573 | initializeToken('selector'); 574 | pushState('selector'); 575 | break; 576 | 577 | case 'before-value': 578 | replaceState('value'); 579 | buffer += ch; 580 | break; 581 | 582 | default: 583 | buffer += ch; 584 | } 585 | break; 586 | 587 | // @-rules 588 | case '@': 589 | switch (getState()) { 590 | case 'comment': 591 | case 'double-string': 592 | case 'single-string': 593 | buffer += ch; 594 | break; 595 | case 'before-value': 596 | replaceState('value'); 597 | buffer += ch; 598 | break; 599 | 600 | default: 601 | // Iterate over the supported @-rules and attempt to tokenize one. 602 | var tokenized = false; 603 | var name; 604 | var rule; 605 | 606 | for (var j = 0, len = atRules.length; !tokenized && j < len; ++j) { 607 | rule = atRules[j]; 608 | name = rule.name || rule; 609 | 610 | if (!isNextString(name)) { continue; } 611 | 612 | tokenized = true; 613 | 614 | initializeToken(name); 615 | pushState(rule.state || 'at-group'); 616 | skip(name.length); 617 | 618 | if (rule.prefix) { 619 | token.prefix = rule.prefix; 620 | } 621 | 622 | if (rule.type) { 623 | token.type = rule.type; 624 | } 625 | } 626 | 627 | if (!tokenized) { 628 | // Keep on truckin' America! 629 | buffer += ch; 630 | } 631 | break; 632 | } 633 | break; 634 | 635 | // Parentheses are tracked to disambiguate semi-colons, such as within a 636 | // data URI. 637 | case '(': 638 | switch (getState()) { 639 | case 'value': 640 | pushState('value-paren'); 641 | break; 642 | case 'before-value': 643 | replaceState('value'); 644 | break; 645 | } 646 | 647 | buffer += ch; 648 | break; 649 | 650 | case ')': 651 | switch (getState()) { 652 | case 'value-paren': 653 | popState(); 654 | break; 655 | case 'before-value': 656 | replaceState('value'); 657 | break; 658 | } 659 | 660 | buffer += ch; 661 | break; 662 | 663 | default: 664 | switch (getState()) { 665 | case 'before-selector': 666 | initializeToken('selector'); 667 | pushState('selector'); 668 | break; 669 | 670 | case 'before-name': 671 | initializeToken('property'); 672 | replaceState('name'); 673 | break; 674 | 675 | case 'before-value': 676 | replaceState('value'); 677 | break; 678 | 679 | case 'before-at-value': 680 | replaceState('at-value'); 681 | break; 682 | } 683 | 684 | buffer += ch; 685 | break; 686 | } 687 | } 688 | 689 | TIMER && debug('ran in', (Date.now() - start) + 'ms'); 690 | 691 | return tokens; 692 | } 693 | -------------------------------------------------------------------------------- /test/fixtures/tests.js: -------------------------------------------------------------------------------- 1 | exports = module.exports = {}; 2 | 3 | // -- Comment ------------------------------------------------------------------ 4 | 5 | exports.comment = { 6 | css: '/* body { color: black; } */', 7 | 8 | lex: [{ 9 | type: 'comment', 10 | text: ' body { color: black; } ', 11 | start: { line: 1, col: 1 }, 12 | end: { line: 1, col: 28 } 13 | }], 14 | 15 | parse: [{ 16 | options: { comments: true }, 17 | 18 | expect : { 19 | type: "stylesheet", 20 | stylesheet: { 21 | rules: [{ 22 | type: 'comment', 23 | text: ' body { color: black; } ' 24 | }] 25 | } 26 | } 27 | }, { 28 | options: {}, 29 | expect: { 30 | type: "stylesheet", 31 | stylesheet: { 32 | rules: [] 33 | } 34 | } 35 | }], 36 | 37 | stringify: { 38 | options: { comments: true } 39 | } 40 | }; 41 | 42 | // -- Rule --------------------------------------------------------------------- 43 | 44 | var lexTokens = [{ 45 | type: 'selector', 46 | text: 'body', 47 | start: { col: 1, line: 1 }, 48 | end: { col: 6, line: 1 } 49 | }, { 50 | type: 'property', 51 | name: 'color', 52 | value: 'black', 53 | start: { col: 8, line: 1 }, 54 | end: { col: 20, line: 1 } 55 | }, { 56 | type: 'end', 57 | start: { col: 22, line: 1 }, 58 | end: { col: 22, line: 1 } 59 | }]; 60 | 61 | var stylesheet = { 62 | type: "stylesheet", 63 | stylesheet: { 64 | rules: [{ 65 | selectors: ['body'], 66 | type: 'rule', 67 | declarations: [{ 68 | type: 'property', 69 | name: 'color', 70 | value: 'black' 71 | }] 72 | }] 73 | } 74 | }; 75 | 76 | exports.rule = { 77 | css: 'body { color: black; }', 78 | 79 | lex: lexTokens, 80 | 81 | parse: [{ 82 | expect: stylesheet 83 | }, { 84 | css: lexTokens, 85 | expect: stylesheet 86 | }] 87 | }; 88 | 89 | // -- @charset ----------------------------------------------------------------- 90 | 91 | exports['@charset'] = { 92 | css: '@charset "UTF-8";', 93 | 94 | lex: [{ 95 | type: 'charset', 96 | value: '"UTF-8"', 97 | start: { col: 1, line: 1 }, 98 | end: { col: 17, line: 1 } 99 | }], 100 | 101 | parse: [{ 102 | expect: { 103 | type: "stylesheet", 104 | stylesheet: { 105 | rules: [{ 106 | type: 'charset', 107 | value: '"UTF-8"' 108 | }] 109 | } 110 | } 111 | }] 112 | }; 113 | 114 | // -- @document ---------------------------------------------------------------- 115 | 116 | exports['@document'] = { 117 | css: ['@document url(http://www.w3.org/),' + 118 | 'url-prefix(http://www.w3.org/Style/),' + 119 | 'domain(mozilla.org),' + 120 | 'regexp("https:.*") {', 121 | 'body {', 122 | 'color: purple;', 123 | 'background: yellow;', 124 | '}', 125 | '}'].join(' '), 126 | 127 | lex: [{ 128 | type: 'document', 129 | name: 'url(http://www.w3.org/),' + 130 | 'url-prefix(http://www.w3.org/Style/),' + 131 | 'domain(mozilla.org),' + 132 | 'regexp("https:.*")', 133 | start: { col: 1, line: 1 }, 134 | end: { col: 111, line: 1 } 135 | }, { 136 | type: 'selector', 137 | start: { line: 1, col: 113 }, 138 | text: 'body', 139 | end: { line: 1, col: 118 } 140 | }, { 141 | type: 'property', 142 | start: { line: 1, col: 120 }, 143 | name: 'color', 144 | value: 'purple', 145 | end: { line: 1, col: 133 } 146 | }, { 147 | type: 'property', 148 | start: { line: 1, col: 135 }, 149 | name: 'background', 150 | value: 'yellow', 151 | end: { line: 1, col: 153 } 152 | }, { 153 | type: 'end', 154 | start: { line: 1, col: 155 }, 155 | end: { line: 1, col: 155 } 156 | }, { 157 | type: 'at-group-end', 158 | start: { line: 1, col: 157 }, 159 | end: { line: 1, col: 157 } 160 | }], 161 | 162 | parse: [{ 163 | expect: { 164 | type: "stylesheet", 165 | stylesheet: { 166 | rules: [{ 167 | type: 'document', 168 | name: 'url(http://www.w3.org/),url-prefix(http://www.w3.org/Style/),domain(mozilla.org),regexp("https:.*")', 169 | prefix: undefined, 170 | rules: [{ 171 | type: 'rule', 172 | selectors: ['body'], 173 | declarations: [{ 174 | type: 'property', 175 | name: 'color', 176 | value: 'purple', 177 | }, { 178 | type: 'property', 179 | name: 'background', 180 | value: 'yellow', 181 | }] 182 | }] 183 | }] 184 | } 185 | } 186 | }, { 187 | css: ['@-moz-document url(http://www.w3.org/),' + 188 | 'url-prefix(http://www.w3.org/Style/),' + 189 | 'domain(mozilla.org),' + 190 | 'regexp("https:.*") {', 191 | 'body {', 192 | 'color: purple;', 193 | 'background: yellow;', 194 | '}', 195 | '}'].join(' '), 196 | 197 | expect: { 198 | type: "stylesheet", 199 | stylesheet: { 200 | rules: [{ 201 | type: 'document', 202 | name: 'url(http://www.w3.org/),url-prefix(http://www.w3.org/Style/),domain(mozilla.org),regexp("https:.*")', 203 | prefix: '-moz-', 204 | rules: [{ 205 | type: 'rule', 206 | selectors: ['body'], 207 | declarations: [{ 208 | type: 'property', 209 | name: 'color', 210 | value: 'purple', 211 | }, { 212 | type: 'property', 213 | name: 'background', 214 | value: 'yellow', 215 | }] 216 | }] 217 | }] 218 | } 219 | } 220 | }] 221 | }; 222 | 223 | // -- @import ------------------------------------------------------------------ 224 | 225 | exports['@import'] = { 226 | css: '@import "foo.css" print;', 227 | 228 | lex: [{ 229 | type: 'import', 230 | value: '"foo.css" print', 231 | start: { col: 1, line: 1 }, 232 | end: { col: 24, line: 1 } 233 | }], 234 | 235 | parse: [{ 236 | expect: { 237 | type: "stylesheet", 238 | stylesheet: { 239 | rules: [{ 240 | type: 'import', 241 | value: '"foo.css" print' 242 | }] 243 | } 244 | } 245 | }] 246 | }; 247 | 248 | // -- @font-face --------------------------------------------------------------- 249 | 250 | exports['@font-face'] = { 251 | css: '@font-face { font-family: Gentium; src: url(http://example.com/fonts/Gentium.ttf);}', 252 | 253 | lex: [{ 254 | type: 'font-face', 255 | name: '', 256 | start: { col: 1, line: 1 }, 257 | end: { col: 12, line: 1 } 258 | }, { 259 | type: 'property', 260 | name: 'font-family', 261 | value: 'Gentium', 262 | start: { col: 14, line: 1 }, 263 | end: { col: 34, line: 1 } 264 | }, { 265 | type: 'property', 266 | name: 'src', 267 | value: 'url(http://example.com/fonts/Gentium.ttf)', 268 | start: { col: 36, line: 1 }, 269 | end: { col: 82, line: 1 } 270 | }, { 271 | type: 'end', 272 | start: { col: 83, line: 1 }, 273 | end: { col: 83, line: 1 } 274 | }, { 275 | type: 'at-group-end', 276 | start: { col: 83, line: 1 }, 277 | end: { col: 83, line: 1 } 278 | }], 279 | 280 | parse: [{ 281 | expect: { 282 | type: "stylesheet", 283 | stylesheet: { 284 | rules: [{ 285 | type: 'font-face', 286 | declarations: [{ 287 | type: 'property', 288 | name: 'font-family', 289 | value: 'Gentium' 290 | }, { 291 | type: 'property', 292 | name: 'src', 293 | value: 'url(http://example.com/fonts/Gentium.ttf)' 294 | }] 295 | }] 296 | } 297 | } 298 | }] 299 | }; 300 | 301 | // -- @keyframes --------------------------------------------------------------- 302 | 303 | exports['@keyframes'] = { 304 | css: '@keyframes foo { from { opacity: 0; } to { opacity: 1; } }', 305 | 306 | lex: [{ 307 | type: 'keyframes', 308 | name: 'foo', 309 | start: { line: 1, col: 1 }, 310 | end: { line: 1, col: 16 } 311 | }, { 312 | type: 'selector', 313 | text: 'from', 314 | start: { line: 1, col: 18 }, 315 | end: { line: 1, col: 23 } 316 | }, { 317 | type: 'property', 318 | name: 'opacity', 319 | value: '0', 320 | start: { line: 1, col: 25 }, 321 | end: { line: 1, col: 35 } 322 | }, { 323 | type: 'end', 324 | start: { line: 1, col: 37 }, 325 | end: { line: 1, col: 37 } 326 | }, { 327 | type: 'selector', 328 | text: 'to', 329 | start: { line: 1, col: 39 }, 330 | end: { line: 1, col: 42 } 331 | }, { 332 | type: 'property', 333 | name: 'opacity', 334 | value: '1', 335 | start: { line: 1, col: 44 }, 336 | end: { line: 1, col: 54 } 337 | }, { 338 | type: 'end', 339 | start: { line: 1, col: 56 }, 340 | end: { line: 1, col: 56 } 341 | }, { 342 | type: 'at-group-end', 343 | start: { line: 1, col: 58 }, 344 | end: { line: 1, col: 58 } 345 | }], 346 | 347 | parse: [{ 348 | expect: { 349 | type: "stylesheet", 350 | stylesheet: { 351 | rules: [{ 352 | type: 'keyframes', 353 | name: 'foo', 354 | prefix: undefined, 355 | rules: [{ 356 | type: 'rule', 357 | selectors: ['from'], 358 | declarations: [{ 359 | name: 'opacity', 360 | type: 'property', 361 | value: '0' 362 | }] 363 | }, { 364 | type: 'rule', 365 | selectors: ['to'], 366 | declarations: [{ 367 | name: 'opacity', 368 | type: 'property', 369 | value: '1' 370 | }] 371 | }] 372 | }] 373 | } 374 | } 375 | }, { 376 | css: '@-webkit-keyframes foo { from { opacity: 0; } to { opacity: 1; } }', 377 | 378 | expect: { 379 | type: "stylesheet", 380 | stylesheet: { 381 | rules: [{ 382 | type: 'keyframes', 383 | name: 'foo', 384 | prefix: '-webkit-', 385 | rules: [{ 386 | type: 'rule', 387 | selectors: ['from'], 388 | declarations: [{ 389 | name: 'opacity', 390 | type: 'property', 391 | value: '0' 392 | }] 393 | }, { 394 | type: 'rule', 395 | selectors: ['to'], 396 | declarations: [{ 397 | name: 'opacity', 398 | type: 'property', 399 | value: '1' 400 | }] 401 | }] 402 | }] 403 | } 404 | } 405 | }, { 406 | css: [ 407 | '@-ms-keyframes boom {', 408 | 'from {', 409 | 'background-position: 0 0;', 410 | '}', 411 | 'to {', 412 | 'background-position: 100% 100%;', 413 | '}', 414 | '}' 415 | ].join(' '), 416 | 417 | expect: { 418 | type: "stylesheet", 419 | stylesheet: { 420 | rules: [{ 421 | type: 'keyframes', 422 | name: 'boom', 423 | prefix: '-ms-', 424 | rules: [{ 425 | type: 'rule', 426 | selectors: ['from'], 427 | declarations: [{ 428 | name: 'background-position', 429 | type: 'property', 430 | value: '0 0' 431 | }] 432 | }, { 433 | type: 'rule', 434 | selectors: ['to'], 435 | declarations: [{ 436 | name: 'background-position', 437 | type: 'property', 438 | value: '100% 100%' 439 | }] 440 | }] 441 | }] 442 | } 443 | } 444 | }] 445 | }; 446 | 447 | // -- @media ------------------------------------------------------------------- 448 | 449 | exports['@media'] = { 450 | css: '@media screen and (min-width: 700px) { body { color: black; } }', 451 | 452 | lex: [{ 453 | type: 'media', 454 | name: 'screen and (min-width: 700px)', 455 | start: { line: 1, col: 1 }, 456 | end: { line: 1, col: 38 } 457 | }, { 458 | type: 'selector', 459 | text: 'body', 460 | start: { line: 1, col: 40 }, 461 | end: { line: 1, col: 45 } 462 | }, { 463 | type: 'property', 464 | name: 'color', 465 | value: 'black', 466 | start: { line: 1, col: 47 }, 467 | end: { line: 1, col: 59 } 468 | }, { 469 | type: 'end', 470 | start: { line: 1, col: 61 }, 471 | end: { line: 1, col: 61 } 472 | }, { 473 | type: 'at-group-end', 474 | start: { line: 1, col: 63 }, 475 | end: { line: 1, col: 63 } 476 | }], 477 | 478 | parse: [{ 479 | expect: { 480 | type: "stylesheet", 481 | stylesheet: { 482 | rules: [{ 483 | name: 'screen and (min-width: 700px)', 484 | type: 'media', 485 | prefix: undefined, 486 | rules: [{ 487 | type: 'rule', 488 | selectors: ['body'], 489 | declarations: [{ 490 | name: 'color', 491 | value: 'black', 492 | type: 'property' 493 | }] 494 | }] 495 | }] 496 | } 497 | } 498 | }] 499 | }; 500 | 501 | // -- @namespace --------------------------------------------------------------- 502 | 503 | exports['@namespace'] = { 504 | css: '@namespace url(http://www.w3.org/1999/xhtml); ' + 505 | '@namespace svg url(http://www.w3.org/2000/svg); ' + 506 | '@namespace "booga";', 507 | 508 | lex: [{ 509 | type: 'namespace', 510 | start: { line: 1, col: 1 }, 511 | value: 'url(http://www.w3.org/1999/xhtml)', 512 | end: { line: 1, col: 45 } 513 | }, { 514 | type: 'namespace', 515 | start: { line: 1, col: 47 }, 516 | value: 'svg url(http://www.w3.org/2000/svg)', 517 | end: { line: 1, col: 93 } 518 | }, { 519 | type: 'namespace', 520 | start: { line: 1, col: 95 }, 521 | value: '"booga"', 522 | end: { line: 1, col: 113 } 523 | }], 524 | 525 | parse: [{ 526 | expect: { 527 | type: "stylesheet", 528 | stylesheet: { 529 | rules: [{ 530 | type: "namespace", 531 | value: "url(http://www.w3.org/1999/xhtml)" 532 | }, { 533 | type: "namespace", 534 | value: "svg url(http://www.w3.org/2000/svg)" 535 | }, { 536 | type: "namespace", 537 | value: "\"booga\"" 538 | }] 539 | } 540 | } 541 | }] 542 | }; 543 | 544 | // -- @page -------------------------------------------------------------------- 545 | 546 | exports['@page'] = { 547 | css: '@page :pseudo-class { margin: 2in; }', 548 | 549 | lex: [{ 550 | type: 'page', 551 | start: { line: 1, col: 1 }, 552 | name: ':pseudo-class', 553 | end: { line: 1, col: 21 } 554 | }, { type: 'property', 555 | start: { line: 1, col: 23 }, 556 | name: 'margin', 557 | value: '2in', 558 | end: { line: 1, col: 34 } 559 | }, { type: 'end', 560 | start: { line: 1, col: 36 }, 561 | end: { line: 1, col: 36 } 562 | }, { type: 'at-group-end', 563 | start: { line: 1, col: 36 }, 564 | end: { line: 1, col: 36 } 565 | }], 566 | 567 | parse: [{ 568 | expect: { 569 | type: "stylesheet", 570 | stylesheet: { 571 | rules: [{ 572 | type: "page", 573 | name: ":pseudo-class", 574 | prefix: undefined, 575 | declarations: [{ 576 | type: "property", 577 | name: "margin", 578 | value: "2in" 579 | }] 580 | }] 581 | } 582 | } 583 | }] 584 | }; 585 | 586 | // -- @supports ---------------------------------------------------------------- 587 | 588 | exports['@supports'] = { 589 | css: '@supports (display: table-cell) { body { color: black; } }', 590 | 591 | lex: [{ 592 | type: 'supports', 593 | name: '(display: table-cell)', 594 | start: { line: 1, col: 1 }, 595 | end: { line: 1, col: 33 } 596 | }, { 597 | type: 'selector', 598 | text: 'body', 599 | start: { line: 1, col: 35 }, 600 | end: { line: 1, col: 40 } 601 | }, { 602 | type: 'property', 603 | name: 'color', 604 | value: 'black', 605 | start: { line: 1, col: 42 }, 606 | end: { line: 1, col: 54 } 607 | }, { 608 | type: 'end', 609 | start: { line: 1, col: 56 }, 610 | end: { line: 1, col: 56 } 611 | }, { 612 | type: 'at-group-end', 613 | start: { line: 1, col: 58 }, 614 | end: { line: 1, col: 58 } 615 | }], 616 | 617 | parse: [{ 618 | expect: { 619 | type: "stylesheet", 620 | stylesheet: { 621 | rules: [{ 622 | name: '(display: table-cell)', 623 | type: 'supports', 624 | prefix: undefined, 625 | rules: [{ 626 | type: 'rule', 627 | selectors: ['body'], 628 | declarations: [{ 629 | name: 'color', 630 | value: 'black', 631 | type: 'property' 632 | }] 633 | }] 634 | }] 635 | } 636 | } 637 | }] 638 | }; 639 | 640 | // -- @viewport ---------------------------------------------------------------- 641 | 642 | exports['@viewport'] = { 643 | css: '@viewport { width: 320px auto;}', 644 | 645 | lex: [{ 646 | type: 'viewport', 647 | name: '', 648 | start: { col: 1, line: 1 }, 649 | end: { col: 11, line: 1 } 650 | }, { 651 | type: 'property', 652 | name: 'width', 653 | value: '320px auto', 654 | start: { col: 13, line: 1 }, 655 | end: { col: 30, line: 1 } 656 | }, { 657 | type: 'end', 658 | start: { col: 31, line: 1 }, 659 | end: { col: 31, line: 1 } 660 | }, { 661 | type: 'at-group-end', 662 | start: { col: 31, line: 1 }, 663 | end: { col: 31, line: 1 } 664 | }], 665 | 666 | parse: [{ 667 | expect: { 668 | type: "stylesheet", 669 | stylesheet: { 670 | rules: [{ 671 | type: 'viewport', 672 | declarations: [{ 673 | type: 'property', 674 | name: 'width', 675 | value: '320px auto' 676 | }] 677 | }] 678 | } 679 | } 680 | }] 681 | }; 682 | 683 | 684 | // -- nested @-groups ---------------------------------------------------------- 685 | 686 | exports['nested @-groups'] = { 687 | css: [ 688 | '@media print {', 689 | '@font-face {', 690 | 'font-family: Gentium;', 691 | 'src: url(http://example.com/fonts/Gentium.ttf);', 692 | '}', 693 | '@viewport {', 694 | 'width: 320px auto;', 695 | '}', 696 | '}' 697 | ].join(' '), 698 | 699 | lex: [{ 700 | type: 'media', 701 | name: 'print', 702 | start: { line: 1, col: 1 }, 703 | end: { line: 1, col: 14 } 704 | }, { 705 | type: 'font-face', 706 | name: '', 707 | start: { line: 1, col: 16 }, 708 | end: { line: 1, col: 27 } 709 | }, { 710 | type: 'property', 711 | name: 'font-family', 712 | value: 'Gentium', 713 | start: { line: 1, col: 29 }, 714 | end: { line: 1, col: 49 } 715 | }, { 716 | type: 'property', 717 | name: 'src', 718 | value: 'url(http://example.com/fonts/Gentium.ttf)', 719 | start: { line: 1, col: 51 }, 720 | end: { line: 1, col: 97 } 721 | }, { 722 | type: 'end', 723 | start: { line: 1, col: 99 }, 724 | end: { line: 1, col: 99 } 725 | }, { 726 | type: 'at-group-end', 727 | start: { line: 1, col: 99 }, 728 | end: { line: 1, col: 99 } 729 | }, { 730 | type: 'viewport', 731 | name: '', 732 | start: { line: 1, col: 101 }, 733 | end: { line: 1, col: 111 } 734 | }, { 735 | type: 'property', 736 | name: 'width', 737 | value: '320px auto', 738 | start: { line: 1, col: 113 }, 739 | end: { line: 1, col: 130 } 740 | }, { 741 | type: 'end', 742 | start: { line: 1, col: 132 }, 743 | end: { line: 1, col: 132 } 744 | }, { 745 | type: 'at-group-end', 746 | start: { line: 1, col: 132 }, 747 | end: { line: 1, col: 132 } 748 | }, { 749 | type: 'at-group-end', 750 | start: { line: 1, col: 134 }, 751 | end: { line: 1, col: 134 } 752 | }], 753 | 754 | parse: [{ 755 | expect: { 756 | type: "stylesheet", 757 | stylesheet: { 758 | rules: [{ 759 | type: 'media', 760 | name: 'print', 761 | prefix: undefined, 762 | rules: [{ 763 | type: 'font-face', 764 | declarations: [{ 765 | type: 'property', 766 | name: 'font-family', 767 | value: 'Gentium' 768 | }, { 769 | type: 'property', 770 | name: 'src', 771 | value: 'url(http://example.com/fonts/Gentium.ttf)' 772 | }] 773 | }, { 774 | type: 'viewport', 775 | declarations: [{ 776 | type: 'property', 777 | name: 'width', 778 | value: '320px auto' 779 | }] 780 | }] 781 | }] 782 | } 783 | } 784 | }] 785 | }; 786 | 787 | 788 | // -- Position ----------------------------------------------------------------- 789 | 790 | exports.position = { 791 | css: '.foo { color: black; }', 792 | 793 | lex: [{ 794 | type: "selector", 795 | start: { line: 1, col: 1 }, 796 | text: ".foo", 797 | end: { line: 1, col: 6} 798 | }, 799 | { 800 | type: "property", 801 | name: "color", 802 | value: "black", 803 | start: { line :1, col: 8 }, 804 | end: { line: 1, col: 20 } 805 | }, { 806 | type: 'end', 807 | start: { line: 1, col: 22 }, 808 | end: { line: 1, col: 22 } 809 | }], 810 | 811 | parse: [{ 812 | options: { position: true }, 813 | 814 | expect: { 815 | type: "stylesheet", 816 | stylesheet: { 817 | rules: [{ 818 | type: 'rule', 819 | selectors: ['.foo'], 820 | declarations: [{ 821 | type: 'property', 822 | name: 'color', 823 | value: 'black', 824 | position: { 825 | start: { line: 1, col: 8 }, 826 | end: { line: 1, col: 20 } 827 | } 828 | }], 829 | position: { 830 | start: { line: 1, col: 1 }, 831 | end: { line: 1, col: 6 } 832 | } 833 | }] 834 | } 835 | } 836 | }] 837 | }; --------------------------------------------------------------------------------