├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── package.json └── src ├── __tests__ ├── api.js └── index.js ├── index.js ├── nodes ├── Container.js └── Node.js └── parsers.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | - "4" 5 | - "0.12" 6 | before_install: 7 | - "npm install -g npm@^3" 8 | env: 9 | - CXX=g++-4.8 10 | addons: 11 | apt: 12 | sources: 13 | - ubuntu-toolchain-r-test 14 | packages: 15 | - g++-4.8 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.3 2 | 3 | * Removed: `/src` directory from the NPM package. 4 | 5 | # 0.2.2 6 | 7 | * Fixed: walk would throw if `filter` argument is not passed. 8 | 9 | # 0.2.1 10 | 11 | * Fixed: the module failing with TypeError in Node.js 0.12. 12 | 13 | # 0.2.0 14 | 15 | * Added: `parent` property to all nodes that are inside a container. 16 | * Added: `colon` type of a node. 17 | 18 | # 0.1.0 19 | 20 | Initial release 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # postcss-media-query-parser 2 | 3 | [![NPM version](http://img.shields.io/npm/v/postcss-media-query-parser.svg)](https://www.npmjs.com/package/postcss-media-query-parser) [![Build Status](https://travis-ci.org/dryoma/postcss-media-query-parser.svg?branch=master)](https://travis-ci.org/dryoma/postcss-media-query-parser) 4 | 5 | Media query parser with very simple traversing functionality. 6 | 7 | ## Installation and usage 8 | 9 | First install it via NPM: 10 | 11 | ``` 12 | npm install postcss-media-query-parser 13 | ``` 14 | 15 | Then in your Node.js application: 16 | 17 | ```js 18 | import mediaParser from "postcss-media-query-parser"; 19 | 20 | const mediaQueryString = "(max-width: 100px), not print"; 21 | const result = mediaParser(mediaQueryString); 22 | ``` 23 | 24 | The `result` will be this object: 25 | 26 | ```js 27 | { 28 | type: 'media-query-list', 29 | value: '(max-width: 100px), not print', 30 | after: '', 31 | before: '', 32 | sourceIndex: 0, 33 | 34 | // the first media query 35 | nodes: [{ 36 | type: 'media-query', 37 | value: '(max-width: 100px)', 38 | before: '', 39 | after: '', 40 | sourceIndex: 0, 41 | parent: , 42 | nodes: [{ 43 | type: 'media-feature-expression', 44 | value: '(max-width: 100px)', 45 | before: '', 46 | after: '', 47 | sourceIndex: 0, 48 | parent: , 49 | nodes: [{ 50 | type: 'media-feature', 51 | value: 'max-width', 52 | before: '', 53 | after: '', 54 | sourceIndex: 1, 55 | parent: , 56 | }, { 57 | type: 'colon', 58 | value: ':', 59 | before: '', 60 | after: ' ', 61 | sourceIndex: 10, 62 | parent: , 63 | }, { 64 | type: 'value', 65 | value: '100px', 66 | before: ' ', 67 | after: '', 68 | sourceIndex: 12, 69 | parent: , 70 | }] 71 | }] 72 | }, 73 | // the second media query 74 | { 75 | type: 'media-query', 76 | value: 'not print', 77 | before: ' ', 78 | after: '', 79 | sourceIndex: 20, 80 | parent: , 81 | nodes: [{ 82 | type: 'keyword', 83 | value: 'not', 84 | before: ' ', 85 | after: ' ', 86 | sourceIndex: 20, 87 | parent: , 88 | }, { 89 | type: 'media-type', 90 | value: 'print', 91 | before: ' ', 92 | after: '', 93 | sourceIndex: 24, 94 | parent: , 95 | }] 96 | }] 97 | } 98 | ``` 99 | 100 | One of the likely sources of a string to parse would be traversing [a PostCSS container node](http://api.postcss.org/Root.html) and getting the `params` property of nodes with the name of "atRule": 101 | 102 | ```js 103 | import postcss from "postcss"; 104 | import mediaParser from "postcss-media-query-parser"; 105 | 106 | const root = postcss.parse(); 107 | // ... or any other way to get sucn container 108 | 109 | root.walkAtRules("media", (atRule) => { 110 | const mediaParsed = mediaParser(atRule.params); 111 | // Do something with "mediaParsed" object 112 | }); 113 | ``` 114 | 115 | ## Nodes 116 | 117 | Node is a very generic item in terms of this parser. It's is pretty much everything that ends up in the parsed result. Each node has these properties: 118 | 119 | * `type`: the type of the node (see below); 120 | * `value`: the node's value stripped of trailing whitespaces; 121 | * `sourceIndex`: 0-based index of the node start relative to the source start (excluding trailing whitespaces); 122 | * `before`: a string that contain a whitespace between the node start and the previous node end/source start; 123 | * `after`: a string that contain a whitespace between the node end and the next node start/source end; 124 | * `parent`: a link to this node's parent node (a container). 125 | 126 | A node can have one of these types (according to [the 2012 CSS3 standard](https://www.w3.org/TR/2012/REC-css3-mediaqueries-20120619/)): 127 | 128 | * `media-query-list`: that is the root level node of the parsing result. A [container](#containers); its children can have types of `url` and `media-query`. 129 | * `url`: if a source is taken from a CSS `@import` rule, it will have a `url(...)` function call. The value of such node will be `url(http://uri-address)`, it is to be parsed separately. 130 | * `media-query`: such nodes correspond to each media query in a comma separated list. In the exapmle above there are two. Nodes of this type are [containers](#containers). 131 | * `media-type`: `screen`, `tv` and other media types. 132 | * `keyword`: `only`, `not` or `and` keyword. 133 | * `media-feature-expression`: an expression in parentheses that checks for a condition of a particular media feature. The value would be like this: `(max-width: 1000px)`. Such nodes are [containers](#containers). They always have a `media-feature` child node, but might not have a `value` child node (like in `screen and (color)`). 134 | * `media-feature`: a media feature, e.g. `max-width`. 135 | * `colon`: present if a media feature expression has a colon (e.g. `(min-width: 1000px)`, compared to `(color)`). 136 | * `value`: a media feature expression value, e.g. `100px` in `(max-width: 1000px)`. 137 | 138 | ### Parsing details 139 | 140 | postcss-media-query-parser allows for cases of some **non-standard syntaxes** and tries its best to work them around. For example, in a media query from a code with SCSS syntax: 141 | 142 | ```scss 143 | @media #{$media-type} and ( #{"max-width" + ": 10px"} ) { ... } 144 | ``` 145 | 146 | `#{$media-type}` will be the node of type `media-type`, alghough `$media-type`'s value can be `only screen`. And inside `media-feature-expression` there will only be a `media-feature` type node with the value of `#{"max-width" + ": 10px"}` (this example doesn't make much sense, it's for demo purpose). 147 | 148 | But the result of parsing **malformed media queries** (such as with incorrect amount of closing parens, curly braces, etc.) can be unexpected. For exapmle, parsing: 149 | 150 | ```scss 151 | @media ((min-width: -100px) 152 | ``` 153 | 154 | would return a media query list with the single `media-query` node that has no child nodes. 155 | 156 | ## Containers 157 | 158 | Containers are [nodes](#nodes) that have other nodes as children. Container nodes have an additional property `nodes` which is an array of their child nodes. And also these methods: 159 | 160 | * `each(callback)` - traverses the direct child nodes of a container, calling `callback` function for each of them. Returns `false` if traversing has stopped by means of `callback` returning `false`, and `true` otherwise. 161 | * `walk([filter, ]callback)` - traverses ALL descendant nodes of a container, calling `callback` function for each of them. Returns `false` if traversing has stopped by means of `callback` returning `false`, and `true` otherwise. 162 | 163 | In both cases `callback` takes these parameters: 164 | 165 | - `node` - the current node (one of the container's descendats, that the callback has been called against). 166 | - `i` - 0-based index of the `node` in an array of its parent's children. 167 | - `nodes` - array of child nodes of `node`'s parent. 168 | 169 | If `callback` returns `false`, the traversing stops. 170 | 171 | ## License 172 | 173 | MIT 174 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-media-query-parser", 3 | "version": "0.2.3", 4 | "description": "A tool for parsing media query lists.", 5 | "main": "dist/index.js", 6 | "keywords": [ 7 | "postcss", 8 | "postcss tool", 9 | "media query", 10 | "media query parsing" 11 | ], 12 | "author": "dryoma", 13 | "license": "MIT", 14 | "homepage": "https://github.com/dryoma/postcss-media-query-parser", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/dryoma/postcss-media-query-parser.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/dryoma/postcss-media-query-parser/issues" 21 | }, 22 | "devDependencies": { 23 | "babel-cli": "^6.14.0", 24 | "babel-preset-es2015": "^6.14.0", 25 | "babel-register": "^6.14.0", 26 | "eslint": "^2.5.1", 27 | "eslint-config-airbnb": "^6.0.2", 28 | "eslint-plugin-react": "^4.2.3", 29 | "tap-spec": "^4.1.1", 30 | "tape": "^4.6.0" 31 | }, 32 | "scripts": { 33 | "lint": "eslint . --ignore-path .gitignore", 34 | "test": "tape -r babel-register \"src/**/__tests__/*.js\" | tap-spec", 35 | "pretest": "npm run lint", 36 | "prebuild": "rimraf dist", 37 | "prepublish": "npm run build", 38 | "build": "babel src --out-dir dist" 39 | }, 40 | "eslintConfig": { 41 | "extends": "airbnb", 42 | "rules": { 43 | "max-len": [ 44 | 2, 45 | 80, 46 | 4 47 | ], 48 | "func-names": 0 49 | } 50 | }, 51 | "babel": { 52 | "presets": [ 53 | "es2015" 54 | ] 55 | }, 56 | "files": [ 57 | "dist", 58 | "!**/__tests__" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /src/__tests__/api.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import parseMedia from '..'; 3 | 4 | test('Container.walk (`only screen and (color)`)', t => { 5 | const result = parseMedia('only screen and (color)'); 6 | let n = 0; 7 | t.plan(5); 8 | 9 | result.walk(); 10 | t.equal(n, 0, 'Container.walk: passed nothing'); 11 | 12 | n = 0; 13 | result.walk(node => { 14 | if (node.value === 'only') { n++; } 15 | }); 16 | t.equal(n, 1, 'Container.walk: passed funtion'); 17 | 18 | n = 0; 19 | result.walk(() => { n++; }); 20 | // Should be 6: the mq, 3 keywords, 1 media feature expression, 21 | // 1 media feature 22 | t.equal(n, 6, 'Container.walk: traversed all nodes'); 23 | 24 | n = 0; 25 | result.walk('feature', () => { n++; }); 26 | // Should be 2: "media-feature-expression", "media-feature" 27 | t.equal(n, 2, 'Container.walk: filter nodes with string value'); 28 | 29 | n = 0; 30 | result.walk(/feature$/, () => { n++; }); 31 | // Should be one: "media-feature" 32 | t.equal(n, 1, 'Container.walk: filter nodes with regexp'); 33 | }); 34 | 35 | test('Container.each (`only screen and (color)`)', t => { 36 | const result = parseMedia('only screen and (color)'); 37 | let n = 0; 38 | t.plan(4); 39 | 40 | result.each(); 41 | t.equal(n, 0, 'Container.each: passed nothing'); 42 | 43 | n = 0; 44 | result.each(node => { 45 | if (node.type === 'media-query') { n++; } 46 | }); 47 | t.equal(n, 1, 'Container.each: passed funtion'); 48 | 49 | n = 0; 50 | result.each(() => { n++; }); 51 | // Should be 1 for the only media query 52 | t.equal(n, 1, 'Container.each: traversed all child nodes'); 53 | 54 | n = 0; 55 | result.nodes[0].each(() => { n++; }); 56 | // Should be 4: 3 keywords and "media-feature-expression" 57 | t.equal(n, 4, 58 | 'Container.each: traversed all child nodes of a child container'); 59 | }); 60 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import parseMedia from '..'; 3 | 4 | test('`only screen and (color)`.', t => { 5 | const result = parseMedia('only screen and (color)'); 6 | 7 | t.plan(3); 8 | 9 | t.equal(result.nodes.length, 1, 'The number of media queries.'); 10 | t.equal(result.nodes[0].nodes.length, 4, 'The number of elements in a MQ.'); 11 | t.deepEqual(result.nodes[0], { 12 | after: '', 13 | before: '', 14 | type: 'media-query', 15 | value: 'only screen and (color)', 16 | sourceIndex: 0, 17 | parent: result, 18 | nodes: [{ 19 | after: ' ', 20 | before: '', 21 | type: 'keyword', 22 | value: 'only', 23 | sourceIndex: 0, 24 | parent: result.nodes[0], 25 | }, { 26 | after: ' ', 27 | before: ' ', 28 | type: 'media-type', 29 | value: 'screen', 30 | sourceIndex: 5, 31 | parent: result.nodes[0], 32 | }, { 33 | after: ' ', 34 | before: ' ', 35 | type: 'keyword', 36 | value: 'and', 37 | sourceIndex: 12, 38 | parent: result.nodes[0], 39 | }, { 40 | after: '', 41 | before: ' ', 42 | type: 'media-feature-expression', 43 | value: '(color)', 44 | sourceIndex: 16, 45 | nodes: [{ 46 | after: '', 47 | before: '', 48 | type: 'media-feature', 49 | value: 'color', 50 | sourceIndex: 17, 51 | parent: result.nodes[0].nodes[3], 52 | }], 53 | parent: result.nodes[0], 54 | }], 55 | }, 'The structure of an MQ node.'); 56 | }); 57 | 58 | test('`not tv and (min-width: 10px)`.', t => { 59 | const result = parseMedia('not tv and (min-width: 10px)'); 60 | 61 | t.plan(3); 62 | 63 | t.equal(result.nodes.length, 1, 'The number of media queries.'); 64 | t.equal(result.nodes[0].nodes.length, 4, 'The number of elements in a MQ.'); 65 | t.deepEqual(result.nodes[0], { 66 | after: '', 67 | before: '', 68 | type: 'media-query', 69 | value: 'not tv and (min-width: 10px)', 70 | sourceIndex: 0, 71 | parent: result, 72 | nodes: [{ 73 | after: ' ', 74 | before: '', 75 | type: 'keyword', 76 | value: 'not', 77 | sourceIndex: 0, 78 | parent: result.nodes[0], 79 | }, { 80 | after: ' ', 81 | before: ' ', 82 | type: 'media-type', 83 | value: 'tv', 84 | sourceIndex: 4, 85 | parent: result.nodes[0], 86 | }, { 87 | after: ' ', 88 | before: ' ', 89 | type: 'keyword', 90 | value: 'and', 91 | sourceIndex: 7, 92 | parent: result.nodes[0], 93 | }, { 94 | after: '', 95 | before: ' ', 96 | type: 'media-feature-expression', 97 | value: '(min-width: 10px)', 98 | sourceIndex: 11, 99 | nodes: [{ 100 | after: '', 101 | before: '', 102 | type: 'media-feature', 103 | value: 'min-width', 104 | sourceIndex: 12, 105 | parent: result.nodes[0].nodes[3], 106 | }, { 107 | type: 'colon', 108 | value: ':', 109 | after: ' ', 110 | before: '', 111 | sourceIndex: 21, 112 | parent: result.nodes[0].nodes[3], 113 | }, { 114 | after: '', 115 | before: ' ', 116 | type: 'value', 117 | value: '10px', 118 | sourceIndex: 23, 119 | parent: result.nodes[0].nodes[3], 120 | }], 121 | parent: result.nodes[0], 122 | }], 123 | }, 'The structure of an MQ node.'); 124 | }); 125 | 126 | test('`not tv, screen, (max-width: $var)`.', t => { 127 | const result = parseMedia('not tv, screen, (max-width: $var)'); 128 | 129 | t.plan(2); 130 | 131 | t.equal(result.nodes.length, 3, 'The number of media queries.'); 132 | t.deepEqual(result.nodes, [{ 133 | after: '', 134 | before: '', 135 | type: 'media-query', 136 | value: 'not tv', 137 | sourceIndex: 0, 138 | nodes: [{ 139 | after: ' ', 140 | before: '', 141 | type: 'keyword', 142 | value: 'not', 143 | sourceIndex: 0, 144 | parent: result.nodes[0], 145 | }, { 146 | after: '', 147 | before: ' ', 148 | type: 'media-type', 149 | value: 'tv', 150 | sourceIndex: 4, 151 | parent: result.nodes[0], 152 | }], 153 | parent: result, 154 | }, { 155 | after: '', 156 | before: ' ', 157 | type: 'media-query', 158 | value: 'screen', 159 | sourceIndex: 8, 160 | nodes: [{ 161 | after: '', 162 | before: ' ', 163 | type: 'media-type', 164 | value: 'screen', 165 | sourceIndex: 8, 166 | parent: result.nodes[1], 167 | }], 168 | parent: result, 169 | }, { 170 | after: '', 171 | before: ' ', 172 | type: 'media-query', 173 | value: '(max-width: $var)', 174 | sourceIndex: 16, 175 | nodes: [{ 176 | after: '', 177 | before: ' ', 178 | type: 'media-feature-expression', 179 | value: '(max-width: $var)', 180 | sourceIndex: 16, 181 | nodes: [{ 182 | after: '', 183 | before: '', 184 | type: 'media-feature', 185 | value: 'max-width', 186 | sourceIndex: 17, 187 | parent: result.nodes[2].nodes[0], 188 | }, { 189 | type: 'colon', 190 | value: ':', 191 | after: ' ', 192 | before: '', 193 | sourceIndex: 26, 194 | parent: result.nodes[2].nodes[0], 195 | }, { 196 | after: '', 197 | before: ' ', 198 | type: 'value', 199 | value: '$var', 200 | sourceIndex: 28, 201 | parent: result.nodes[2].nodes[0], 202 | }], 203 | parent: result.nodes[2], 204 | }], 205 | parent: result, 206 | }], 'The structure of an MQ node.'); 207 | }); 208 | 209 | // Media query from @import (includes the `url` part) 210 | test('`url(fun()) screen and (color), projection and (color)`.', t => { 211 | const result = 212 | parseMedia('url(fun()) screen and (color), projection and (color)'); 213 | 214 | t.plan(2); 215 | 216 | t.equal(result.nodes.length, 3, 'The number of media queries.'); 217 | t.deepEqual(result.nodes, [{ 218 | after: ' ', 219 | before: '', 220 | type: 'url', 221 | value: 'url(fun())', 222 | sourceIndex: 0, 223 | parent: result, 224 | }, { 225 | after: '', 226 | before: ' ', 227 | type: 'media-query', 228 | value: 'screen and (color)', 229 | sourceIndex: 11, 230 | nodes: [{ 231 | after: ' ', 232 | before: ' ', 233 | type: 'media-type', 234 | value: 'screen', 235 | sourceIndex: 11, 236 | parent: result.nodes[1], 237 | }, { 238 | after: ' ', 239 | before: ' ', 240 | type: 'keyword', 241 | value: 'and', 242 | sourceIndex: 18, 243 | parent: result.nodes[1], 244 | }, { 245 | after: '', 246 | before: ' ', 247 | type: 'media-feature-expression', 248 | value: '(color)', 249 | sourceIndex: 22, 250 | nodes: [{ 251 | after: '', 252 | before: '', 253 | type: 'media-feature', 254 | value: 'color', 255 | sourceIndex: 23, 256 | parent: result.nodes[1].nodes[2], 257 | }], 258 | parent: result.nodes[1], 259 | }], 260 | parent: result, 261 | }, { 262 | after: '', 263 | before: ' ', 264 | type: 'media-query', 265 | value: 'projection and (color)', 266 | sourceIndex: 31, 267 | nodes: [{ 268 | after: ' ', 269 | before: ' ', 270 | type: 'media-type', 271 | value: 'projection', 272 | sourceIndex: 31, 273 | parent: result.nodes[2], 274 | }, { 275 | after: ' ', 276 | before: ' ', 277 | type: 'keyword', 278 | value: 'and', 279 | sourceIndex: 42, 280 | parent: result.nodes[2], 281 | }, { 282 | after: '', 283 | before: ' ', 284 | type: 'media-feature-expression', 285 | value: '(color)', 286 | sourceIndex: 46, 287 | nodes: [{ 288 | after: '', 289 | before: '', 290 | type: 'media-feature', 291 | value: 'color', 292 | sourceIndex: 47, 293 | parent: result.nodes[2].nodes[2], 294 | }], 295 | parent: result.nodes[2], 296 | }], 297 | parent: result, 298 | }], 'The structure of an MQ node.'); 299 | }); 300 | 301 | // Media feature fully consisting of Sass structure, colon inside 302 | test('`( #{"max-width" + ": 10px"} )`.', t => { 303 | const result = parseMedia('( #{"max-width" + ": 10px"} )'); 304 | 305 | t.plan(2); 306 | 307 | t.equal(result.nodes.length, 1, 'The number of media queries.'); 308 | t.deepEqual(result.nodes, [{ 309 | after: '', 310 | before: '', 311 | type: 'media-query', 312 | value: '( #{"max-width" + ": 10px"} )', 313 | sourceIndex: 0, 314 | nodes: [{ 315 | after: '', 316 | before: '', 317 | type: 'media-feature-expression', 318 | value: '( #{"max-width" + ": 10px"} )', 319 | sourceIndex: 0, 320 | nodes: [{ 321 | after: ' ', 322 | before: ' ', 323 | type: 'media-feature', 324 | value: '#{"max-width" + ": 10px"}', 325 | sourceIndex: 2, 326 | parent: result.nodes[0].nodes[0], 327 | }], 328 | parent: result.nodes[0], 329 | }], 330 | parent: result, 331 | }], 'The structure of an MQ node.'); 332 | }); 333 | 334 | test('`#{"scree" + "n"}`.', t => { 335 | const result = parseMedia('#{"scree" + "n"}'); 336 | 337 | t.plan(2); 338 | 339 | t.equal(result.nodes.length, 1, 'The number of media queries.'); 340 | t.deepEqual(result.nodes, [{ 341 | after: '', 342 | before: '', 343 | type: 'media-query', 344 | value: '#{"scree" + "n"}', 345 | sourceIndex: 0, 346 | nodes: [{ 347 | after: '', 348 | before: '', 349 | type: 'media-type', 350 | value: '#{"scree" + "n"}', 351 | sourceIndex: 0, 352 | parent: result.nodes[0], 353 | }], 354 | parent: result, 355 | }], 'The structure of an MQ node.'); 356 | }); 357 | 358 | test('Malformed MQ, expression wrecked: `(example, all,), speech`.', t => { 359 | const result = parseMedia('(example, all,), speech'); 360 | 361 | t.plan(2); 362 | t.equal(result.nodes.length, 2, 'The number of media queries.'); 363 | t.deepEqual(result.nodes, [{ 364 | after: '', 365 | before: '', 366 | type: 'media-query', 367 | value: '(example, all,)', 368 | sourceIndex: 0, 369 | nodes: [{ 370 | after: '', 371 | before: '', 372 | type: 'media-feature-expression', 373 | value: '(example, all,)', 374 | sourceIndex: 0, 375 | nodes: [{ 376 | type: 'media-feature', 377 | before: '', 378 | after: '', 379 | value: 'example, all,', 380 | sourceIndex: 1, 381 | parent: result.nodes[0].nodes[0], 382 | }], 383 | parent: result.nodes[0], 384 | }], 385 | parent: result, 386 | }, { 387 | after: '', 388 | before: ' ', 389 | type: 'media-query', 390 | value: 'speech', 391 | sourceIndex: 17, 392 | nodes: [{ 393 | after: '', 394 | before: ' ', 395 | type: 'media-type', 396 | value: 'speech', 397 | sourceIndex: 17, 398 | parent: result.nodes[1], 399 | }], 400 | parent: result, 401 | }], 'The structure of an MQ node.'); 402 | }); 403 | 404 | test('Malformed MQ, parens don\'t match: `((min-width: -100px)`.', t => { 405 | const result = parseMedia('((min-width: -100px)'); 406 | 407 | t.plan(2); 408 | t.equal(result.nodes.length, 1, 'The number of media queries.'); 409 | t.deepEqual(result.nodes, [{ 410 | after: '', 411 | before: '', 412 | nodes: [], 413 | parent: { 414 | after: '', 415 | before: '', 416 | nodes: result.nodes, 417 | sourceIndex: 0, 418 | type: 'media-query-list', 419 | value: '((min-width: -100px)', 420 | }, 421 | sourceIndex: 0, 422 | type: 'media-query', 423 | value: '((min-width: -100px)', 424 | }], 'The structure of an MQ node.'); 425 | }); 426 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a media query list into an array of nodes. A typical node signature: 3 | * {string} node.type -- one of: 'media-query', 'media-type', 'keyword', 4 | * 'media-feature-expression', 'media-feature', 'colon', 'value' 5 | * {string} node.value -- the contents of a particular element, trimmed 6 | * e.g.: `screen`, `max-width`, `1024px` 7 | * {string} node.after -- whitespaces that follow the element 8 | * {string} node.before -- whitespaces that precede the element 9 | * {string} node.sourceIndex -- the index of the element in a source media 10 | * query list, 0-based 11 | * {object} node.parent -- a link to the parent node (a container) 12 | * 13 | * Some nodes (media queries, media feature expressions) contain other nodes. 14 | * They additionally have: 15 | * {array} node.nodes -- an array of nodes of the type described here 16 | * {funciton} node.each -- traverses direct children of the node, calling 17 | * a callback for each one 18 | * {funciton} node.walk -- traverses ALL descendants of the node, calling 19 | * a callback for each one 20 | */ 21 | 22 | import Container from './nodes/Container'; 23 | 24 | import { parseMediaList } from './parsers'; 25 | 26 | export default function parseMedia(value) { 27 | return new Container({ 28 | nodes: parseMediaList(value), 29 | type: 'media-query-list', 30 | value: value.trim(), 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/nodes/Container.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A node that contains other nodes and support traversing over them 3 | */ 4 | 5 | import Node from './Node'; 6 | 7 | function Container(opts) { 8 | this.constructor(opts); 9 | 10 | this.nodes = opts.nodes; 11 | 12 | if (this.after === undefined) { 13 | this.after = this.nodes.length > 0 ? 14 | this.nodes[this.nodes.length - 1].after : ''; 15 | } 16 | 17 | if (this.before === undefined) { 18 | this.before = this.nodes.length > 0 ? 19 | this.nodes[0].before : ''; 20 | } 21 | 22 | if (this.sourceIndex === undefined) { 23 | this.sourceIndex = this.before.length; 24 | } 25 | 26 | this.nodes.forEach(node => { 27 | node.parent = this; // eslint-disable-line no-param-reassign 28 | }); 29 | } 30 | 31 | Container.prototype = Object.create(Node.prototype); 32 | Container.constructor = Node; 33 | 34 | /** 35 | * Iterate over descendant nodes of the node 36 | * 37 | * @param {RegExp|string} filter - Optional. Only nodes with node.type that 38 | * satisfies the filter will be traversed over 39 | * @param {function} cb - callback to call on each node. Takes theese params: 40 | * node - the node being processed, i - it's index, nodes - the array 41 | * of all nodes 42 | * If false is returned, the iteration breaks 43 | * 44 | * @return (boolean) false, if the iteration was broken 45 | */ 46 | Container.prototype.walk = function walk(filter, cb) { 47 | const hasFilter = typeof filter === 'string' || filter instanceof RegExp; 48 | const callback = hasFilter ? cb : filter; 49 | const filterReg = typeof filter === 'string' ? new RegExp(filter) : filter; 50 | 51 | for (let i = 0; i < this.nodes.length; i ++) { 52 | const node = this.nodes[i]; 53 | const filtered = hasFilter ? filterReg.test(node.type) : true; 54 | if (filtered && callback && callback(node, i, this.nodes) === false) { 55 | return false; 56 | } 57 | if (node.nodes && node.walk(filter, cb) === false) { return false; } 58 | } 59 | return true; 60 | }; 61 | 62 | /** 63 | * Iterate over immediate children of the node 64 | * 65 | * @param {function} cb - callback to call on each node. Takes theese params: 66 | * node - the node being processed, i - it's index, nodes - the array 67 | * of all nodes 68 | * If false is returned, the iteration breaks 69 | * 70 | * @return (boolean) false, if the iteration was broken 71 | */ 72 | Container.prototype.each = function each(cb = () => {}) { 73 | for (let i = 0; i < this.nodes.length; i ++) { 74 | const node = this.nodes[i]; 75 | if (cb(node, i, this.nodes) === false) { return false; } 76 | } 77 | return true; 78 | }; 79 | 80 | export default Container; 81 | -------------------------------------------------------------------------------- /src/nodes/Node.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A very generic node. Pretty much any element of a media query 3 | */ 4 | 5 | function Node(opts) { 6 | this.after = opts.after; 7 | this.before = opts.before; 8 | this.type = opts.type; 9 | this.value = opts.value; 10 | this.sourceIndex = opts.sourceIndex; 11 | } 12 | 13 | export default Node; 14 | -------------------------------------------------------------------------------- /src/parsers.js: -------------------------------------------------------------------------------- 1 | import Node from './nodes/Node'; 2 | import Container from './nodes/Container'; 3 | 4 | /** 5 | * Parses a media feature expression, e.g. `max-width: 10px`, `(color)` 6 | * 7 | * @param {string} string - the source expression string, can be inside parens 8 | * @param {Number} index - the index of `string` in the overall input 9 | * 10 | * @return {Array} an array of Nodes, the first element being a media feature, 11 | * the secont - its value (may be missing) 12 | */ 13 | 14 | export function parseMediaFeature(string, index = 0) { 15 | const modesEntered = [{ 16 | mode: 'normal', 17 | character: null, 18 | }]; 19 | const result = []; 20 | let lastModeIndex = 0; 21 | let mediaFeature = ''; 22 | let colon = null; 23 | let mediaFeatureValue = null; 24 | let indexLocal = index; 25 | 26 | let stringNormalized = string; 27 | // Strip trailing parens (if any), and correct the starting index 28 | if (string[0] === '(' && string[string.length - 1] === ')') { 29 | stringNormalized = string.substring(1, string.length - 1); 30 | indexLocal++; 31 | } 32 | 33 | for (let i = 0; i < stringNormalized.length; i++) { 34 | const character = stringNormalized[i]; 35 | 36 | // If entering/exiting a string 37 | if (character === '\'' || character === '"') { 38 | if (modesEntered[lastModeIndex].isCalculationEnabled === true) { 39 | modesEntered.push({ 40 | mode: 'string', 41 | isCalculationEnabled: false, 42 | character, 43 | }); 44 | lastModeIndex++; 45 | } else if (modesEntered[lastModeIndex].mode === 'string' && 46 | modesEntered[lastModeIndex].character === character && 47 | stringNormalized[i - 1] !== '\\' 48 | ) { 49 | modesEntered.pop(); 50 | lastModeIndex--; 51 | } 52 | } 53 | 54 | // If entering/exiting interpolation 55 | if (character === '{') { 56 | modesEntered.push({ 57 | mode: 'interpolation', 58 | isCalculationEnabled: true, 59 | }); 60 | lastModeIndex++; 61 | } else if (character === '}') { 62 | modesEntered.pop(); 63 | lastModeIndex--; 64 | } 65 | 66 | // If a : is met outside of a string, function call or interpolation, than 67 | // this : separates a media feature and a value 68 | if (modesEntered[lastModeIndex].mode === 'normal' && character === ':') { 69 | const mediaFeatureValueStr = stringNormalized.substring(i + 1); 70 | mediaFeatureValue = { 71 | type: 'value', 72 | before: /^(\s*)/.exec(mediaFeatureValueStr)[1], 73 | after: /(\s*)$/.exec(mediaFeatureValueStr)[1], 74 | value: mediaFeatureValueStr.trim(), 75 | }; 76 | // +1 for the colon 77 | mediaFeatureValue.sourceIndex = 78 | mediaFeatureValue.before.length + i + 1 + indexLocal; 79 | colon = { 80 | type: 'colon', 81 | sourceIndex: i + indexLocal, 82 | after: mediaFeatureValue.before, 83 | value: ':', // for consistency only 84 | }; 85 | break; 86 | } 87 | 88 | mediaFeature += character; 89 | } 90 | 91 | // Forming a media feature node 92 | mediaFeature = { 93 | type: 'media-feature', 94 | before: /^(\s*)/.exec(mediaFeature)[1], 95 | after: /(\s*)$/.exec(mediaFeature)[1], 96 | value: mediaFeature.trim(), 97 | }; 98 | mediaFeature.sourceIndex = mediaFeature.before.length + indexLocal; 99 | result.push(mediaFeature); 100 | 101 | if (colon !== null) { 102 | colon.before = mediaFeature.after; 103 | result.push(colon); 104 | } 105 | 106 | if (mediaFeatureValue !== null) { 107 | result.push(mediaFeatureValue); 108 | } 109 | 110 | return result; 111 | } 112 | 113 | /** 114 | * Parses a media query, e.g. `screen and (color)`, `only tv` 115 | * 116 | * @param {string} string - the source media query string 117 | * @param {Number} index - the index of `string` in the overall input 118 | * 119 | * @return {Array} an array of Nodes and Containers 120 | */ 121 | 122 | export function parseMediaQuery(string, index = 0) { 123 | const result = []; 124 | 125 | // How many timies the parser entered parens/curly braces 126 | let localLevel = 0; 127 | // Has any keyword, media type, media feature expression or interpolation 128 | // ('element' hereafter) started 129 | let insideSomeValue = false; 130 | let node; 131 | 132 | function resetNode() { 133 | return { 134 | before: '', 135 | after: '', 136 | value: '', 137 | }; 138 | } 139 | 140 | node = resetNode(); 141 | 142 | for (let i = 0; i < string.length; i++) { 143 | const character = string[i]; 144 | // If not yet entered any element 145 | if (!insideSomeValue) { 146 | if (character.search(/\s/) !== -1) { 147 | // A whitespace 148 | // Don't form 'after' yet; will do it later 149 | node.before += character; 150 | } else { 151 | // Not a whitespace - entering an element 152 | // Expression start 153 | if (character === '(') { 154 | node.type = 'media-feature-expression'; 155 | localLevel++; 156 | } 157 | node.value = character; 158 | node.sourceIndex = index + i; 159 | insideSomeValue = true; 160 | } 161 | } else { 162 | // Already in the middle of some alement 163 | node.value += character; 164 | 165 | // Here parens just increase localLevel and don't trigger a start of 166 | // a media feature expression (since they can't be nested) 167 | // Interpolation start 168 | if (character === '{' || character === '(') { localLevel++; } 169 | // Interpolation/function call/media feature expression end 170 | if (character === ')' || character === '}') { localLevel--; } 171 | } 172 | 173 | // If exited all parens/curlies and the next symbol 174 | if (insideSomeValue && localLevel === 0 && 175 | (character === ')' || i === string.length - 1 || 176 | string[i + 1].search(/\s/) !== -1) 177 | ) { 178 | if (['not', 'only', 'and'].indexOf(node.value) !== -1) { 179 | node.type = 'keyword'; 180 | } 181 | // if it's an expression, parse its contents 182 | if (node.type === 'media-feature-expression') { 183 | node.nodes = parseMediaFeature(node.value, node.sourceIndex); 184 | } 185 | result.push(Array.isArray(node.nodes) ? 186 | new Container(node) : new Node(node)); 187 | node = resetNode(); 188 | insideSomeValue = false; 189 | } 190 | } 191 | 192 | // Now process the result array - to specify undefined types of the nodes 193 | // and specify the `after` prop 194 | for (let i = 0; i < result.length; i++) { 195 | node = result[i]; 196 | if (i > 0) { result[i - 1].after = node.before; } 197 | 198 | // Node types. Might not be set because contains interpolation/function 199 | // calls or fully consists of them 200 | if (node.type === undefined) { 201 | if (i > 0) { 202 | // only `and` can follow an expression 203 | if (result[i - 1].type === 'media-feature-expression') { 204 | node.type = 'keyword'; 205 | continue; 206 | } 207 | // Anything after 'only|not' is a media type 208 | if (result[i - 1].value === 'not' || result[i - 1].value === 'only') { 209 | node.type = 'media-type'; 210 | continue; 211 | } 212 | // Anything after 'and' is an expression 213 | if (result[i - 1].value === 'and') { 214 | node.type = 'media-feature-expression'; 215 | continue; 216 | } 217 | 218 | if (result[i - 1].type === 'media-type') { 219 | // if it is the last element - it might be an expression 220 | // or 'and' depending on what is after it 221 | if (!result[i + 1]) { 222 | node.type = 'media-feature-expression'; 223 | } else { 224 | node.type = result[i + 1].type === 'media-feature-expression' ? 225 | 'keyword' : 'media-feature-expression'; 226 | } 227 | } 228 | } 229 | 230 | if (i === 0) { 231 | // `screen`, `fn( ... )`, `#{ ... }`. Not an expression, since then 232 | // its type would have been set by now 233 | if (!result[i + 1]) { 234 | node.type = 'media-type'; 235 | continue; 236 | } 237 | 238 | // `screen and` or `#{...} (max-width: 10px)` 239 | if (result[i + 1] && 240 | (result[i + 1].type === 'media-feature-expression' || 241 | result[i + 1].type === 'keyword') 242 | ) { 243 | node.type = 'media-type'; 244 | continue; 245 | } 246 | if (result[i + 2]) { 247 | // `screen and (color) ...` 248 | if (result[i + 2].type === 'media-feature-expression') { 249 | node.type = 'media-type'; 250 | result[i + 1].type = 'keyword'; 251 | continue; 252 | } 253 | // `only screen and ...` 254 | if (result[i + 2].type === 'keyword') { 255 | node.type = 'keyword'; 256 | result[i + 1].type = 'media-type'; 257 | continue; 258 | } 259 | } 260 | if (result[i + 3]) { 261 | // `screen and (color) ...` 262 | if (result[i + 3].type === 'media-feature-expression') { 263 | node.type = 'keyword'; 264 | result[i + 1].type = 'media-type'; 265 | result[i + 2].type = 'keyword'; 266 | continue; 267 | } 268 | } 269 | } 270 | } 271 | } 272 | return result; 273 | } 274 | 275 | /** 276 | * Parses a media query list. Takes a possible `url()` at the start into 277 | * account, and divides the list into media queries that are parsed separately 278 | * 279 | * @param {string} string - the source media query list string 280 | * 281 | * @return {Array} an array of Nodes/Containers 282 | */ 283 | 284 | export function parseMediaList(string) { 285 | const result = []; 286 | let interimIndex = 0; 287 | let levelLocal = 0; 288 | 289 | // Check for a `url(...)` part (if it is contents of an @import rule) 290 | const doesHaveUrl = /^(\s*)url\s*\(/.exec(string); 291 | if (doesHaveUrl !== null) { 292 | let i = doesHaveUrl[0].length; 293 | let parenthesesLv = 1; 294 | while (parenthesesLv > 0) { 295 | const character = string[i]; 296 | if (character === '(') { parenthesesLv++; } 297 | if (character === ')') { parenthesesLv--; } 298 | i++; 299 | } 300 | result.unshift(new Node({ 301 | type: 'url', 302 | value: string.substring(0, i).trim(), 303 | sourceIndex: doesHaveUrl[1].length, 304 | before: doesHaveUrl[1], 305 | after: /^(\s*)/.exec(string.substring(i))[1], 306 | })); 307 | interimIndex = i; 308 | } 309 | 310 | // Start processing the media query list 311 | for (let i = interimIndex; i < string.length; i++) { 312 | const character = string[i]; 313 | 314 | // Dividing the media query list into comma-separated media queries 315 | // Only count commas that are outside of any parens 316 | // (i.e., not part of function call params list, etc.) 317 | if (character === '(') { levelLocal++; } 318 | if (character === ')') { levelLocal--; } 319 | if (levelLocal === 0 && character === ',') { 320 | const mediaQueryString = string.substring(interimIndex, i); 321 | const spaceBefore = /^(\s*)/.exec(mediaQueryString)[1]; 322 | result.push(new Container({ 323 | type: 'media-query', 324 | value: mediaQueryString.trim(), 325 | sourceIndex: interimIndex + spaceBefore.length, 326 | nodes: parseMediaQuery(mediaQueryString, interimIndex), 327 | before: spaceBefore, 328 | after: /(\s*)$/.exec(mediaQueryString)[1], 329 | })); 330 | interimIndex = i + 1; 331 | } 332 | } 333 | 334 | const mediaQueryString = string.substring(interimIndex); 335 | const spaceBefore = /^(\s*)/.exec(mediaQueryString)[1]; 336 | result.push(new Container({ 337 | type: 'media-query', 338 | value: mediaQueryString.trim(), 339 | sourceIndex: interimIndex + spaceBefore.length, 340 | nodes: parseMediaQuery(mediaQueryString, interimIndex), 341 | before: spaceBefore, 342 | after: /(\s*)$/.exec(mediaQueryString)[1], 343 | })); 344 | 345 | return result; 346 | } 347 | --------------------------------------------------------------------------------