├── .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 | [](https://www.npmjs.com/package/postcss-media-query-parser) [](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 |
--------------------------------------------------------------------------------