├── .editorconfig ├── .gitignore ├── .npmignore ├── .snyk ├── .travis.yml ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── lib ├── __tests__ │ ├── __snapshots__ │ │ ├── index.spec.js.snap │ │ ├── input-stream.spec.js.snap │ │ ├── parse.spec.js.snap │ │ └── token-stream.spec.js.snap │ ├── index.spec.js │ ├── input-stream.spec.js │ ├── parse.spec.js │ ├── stringify.spec.js │ └── token-stream.spec.js ├── index.js ├── input-stream.js ├── parse.js ├── stringify.js └── token-stream.js ├── package-lock.json └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.log 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | .travis.yml 3 | CONTRIBUTING.md 4 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.19.0 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | SNYK-JS-MINIMIST-559764: 6 | - minimist: 7 | reason: None given 8 | expires: '2021-08-08T18:23:16.958Z' 9 | - mkdirp > minimist: 10 | reason: None given 11 | expires: '2021-08-08T18:23:16.958Z' 12 | patch: {} 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | script: 5 | - npm run lint 6 | - npm test 7 | cache: 8 | directories: 9 | - node_modules 10 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | #ECCN:EAR99,Open Source -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Code 2 | 3 | External contributors are required to sign a Contributor’s License Agreement. 4 | You will be prompted to sign it when you open a pull request. 5 | 6 | 1. Create a new issue before starting your project so that we can keep 7 | track of what you are trying to add/fix. That way, we can also offer 8 | suggestions or let you know if there is already an effort in progress. 9 | 2. Fork off this repository. 10 | 3. Create a topic branch for the issue that you are trying to add. 11 | When possible, you should branch off the default branch. 12 | 4. Edit the code in your fork. 13 | 5. Send us a well documented pull request when you are done. 14 | 15 | The **GitHub pull requests** should meet the following criteria: 16 | 17 | - Descriptive title 18 | - Brief summary 19 | - @mention several relevant people to review the code 20 | - Add helpful GitHub comments on lines that you have questions / concerns about 21 | 22 | We’ll review your code, suggest any needed changes, and merge it in. Thank you. 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, salesforce.com, inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 5 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 6 | Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 7 | 8 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SCSS Parser 2 | 3 | [![Build Status][travis-image]][travis-url] 4 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=salesforce-ux/scss-parser)](https://dependabot.com) 5 | [![NPM version][npm-image]][npm-url] 6 | 7 | ## Getting Started 8 | 9 | ```javascript 10 | let { parse, stringify } = require('scss-parser') 11 | 12 | // Create an AST from a string of SCSS 13 | let ast = parse('.hello { color: $red; }') 14 | // Modify the AST (see below for a better way to do this) 15 | ast.value[0].value[0].value[0].value[0].value = 'world' 16 | // Convert the modified AST back to SCSS 17 | let scss = stringify(ast) // .world { color: $red; } 18 | ``` 19 | 20 | ## Traversal 21 | 22 | For an easy way to traverse/modify the generated AST, check out [QueryAST](https://github.com/salesforce-ux/query-ast) 23 | 24 | ```javascript 25 | let { parse, stringify } = require('scss-parser') 26 | let createQueryWrapper = require('query-ast') 27 | 28 | // Create an AST 29 | let ast = parse('.hello { color: red; } .world { color: blue; }') 30 | // Create a function to traverse/modify the AST 31 | let $ = createQueryWrapper(ast) 32 | // Make some modifications 33 | $('rule').eq(1).remove() 34 | // Convert the modified AST back to a string 35 | let scss = stringify($().get(0)) 36 | ``` 37 | 38 | ## Running tests 39 | 40 | Clone the repository, then: 41 | 42 | ```bash 43 | npm install 44 | # requires node >= 5.0.0 45 | npm test 46 | ``` 47 | 48 | ## License 49 | 50 | Copyright (c) 2016, salesforce.com, inc. All rights reserved. 51 | 52 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 53 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 54 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 55 | Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 56 | 57 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 58 | 59 | [npm-url]: https://npmjs.org/package/scss-parser 60 | [npm-image]: http://img.shields.io/npm/v/scss-parser.svg 61 | 62 | [travis-url]: https://travis-ci.org/salesforce-ux/scss-parser 63 | [travis-image]: https://travis-ci.org/salesforce-ux/scss-parser.svg?branch=master 64 | -------------------------------------------------------------------------------- /lib/__tests__/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`parse 1`] = ` 4 | Object { 5 | "next": undefined, 6 | "start": undefined, 7 | "type": "stylesheet", 8 | "value": Array [ 9 | Object { 10 | "next": Object { 11 | "column": 23, 12 | "cursor": 23, 13 | "line": 1, 14 | }, 15 | "start": Object { 16 | "column": 0, 17 | "cursor": 0, 18 | "line": 1, 19 | }, 20 | "type": "rule", 21 | "value": Array [ 22 | Object { 23 | "next": Object { 24 | "column": 3, 25 | "cursor": 3, 26 | "line": 1, 27 | }, 28 | "start": Object { 29 | "column": 0, 30 | "cursor": 0, 31 | "line": 1, 32 | }, 33 | "type": "selector", 34 | "value": Array [ 35 | Object { 36 | "next": Object { 37 | "column": 2, 38 | "cursor": 2, 39 | "line": 1, 40 | }, 41 | "start": Object { 42 | "column": 0, 43 | "cursor": 0, 44 | "line": 1, 45 | }, 46 | "type": "class", 47 | "value": Array [ 48 | Object { 49 | "next": Object { 50 | "column": 2, 51 | "cursor": 2, 52 | "line": 1, 53 | }, 54 | "start": Object { 55 | "column": 1, 56 | "cursor": 1, 57 | "line": 1, 58 | }, 59 | "type": "identifier", 60 | "value": "a", 61 | }, 62 | ], 63 | }, 64 | Object { 65 | "next": Object { 66 | "column": 3, 67 | "cursor": 3, 68 | "line": 1, 69 | }, 70 | "start": Object { 71 | "column": 2, 72 | "cursor": 2, 73 | "line": 1, 74 | }, 75 | "type": "space", 76 | "value": " ", 77 | }, 78 | ], 79 | }, 80 | Object { 81 | "next": Object { 82 | "column": 23, 83 | "cursor": 23, 84 | "line": 1, 85 | }, 86 | "start": Object { 87 | "column": 3, 88 | "cursor": 3, 89 | "line": 1, 90 | }, 91 | "type": "block", 92 | "value": Array [ 93 | Object { 94 | "next": Object { 95 | "column": 5, 96 | "cursor": 5, 97 | "line": 1, 98 | }, 99 | "start": Object { 100 | "column": 4, 101 | "cursor": 4, 102 | "line": 1, 103 | }, 104 | "type": "space", 105 | "value": " ", 106 | }, 107 | Object { 108 | "next": undefined, 109 | "start": Object { 110 | "column": 5, 111 | "cursor": 5, 112 | "line": 1, 113 | }, 114 | "type": "declaration", 115 | "value": Array [ 116 | Object { 117 | "next": Object { 118 | "column": 15, 119 | "cursor": 15, 120 | "line": 1, 121 | }, 122 | "start": Object { 123 | "column": 5, 124 | "cursor": 5, 125 | "line": 1, 126 | }, 127 | "type": "property", 128 | "value": Array [ 129 | Object { 130 | "next": Object { 131 | "column": 15, 132 | "cursor": 15, 133 | "line": 1, 134 | }, 135 | "start": Object { 136 | "column": 5, 137 | "cursor": 5, 138 | "line": 1, 139 | }, 140 | "type": "identifier", 141 | "value": "background", 142 | }, 143 | ], 144 | }, 145 | Object { 146 | "next": Object { 147 | "column": 16, 148 | "cursor": 16, 149 | "line": 1, 150 | }, 151 | "start": Object { 152 | "column": 15, 153 | "cursor": 15, 154 | "line": 1, 155 | }, 156 | "type": "punctuation", 157 | "value": ":", 158 | }, 159 | Object { 160 | "next": Object { 161 | "column": 20, 162 | "cursor": 20, 163 | "line": 1, 164 | }, 165 | "start": Object { 166 | "column": 16, 167 | "cursor": 16, 168 | "line": 1, 169 | }, 170 | "type": "value", 171 | "value": Array [ 172 | Object { 173 | "next": Object { 174 | "column": 17, 175 | "cursor": 17, 176 | "line": 1, 177 | }, 178 | "start": Object { 179 | "column": 16, 180 | "cursor": 16, 181 | "line": 1, 182 | }, 183 | "type": "space", 184 | "value": " ", 185 | }, 186 | Object { 187 | "next": Object { 188 | "column": 20, 189 | "cursor": 20, 190 | "line": 1, 191 | }, 192 | "start": Object { 193 | "column": 17, 194 | "cursor": 17, 195 | "line": 1, 196 | }, 197 | "type": "identifier", 198 | "value": "red", 199 | }, 200 | ], 201 | }, 202 | Object { 203 | "next": Object { 204 | "column": 21, 205 | "cursor": 21, 206 | "line": 1, 207 | }, 208 | "start": Object { 209 | "column": 20, 210 | "cursor": 20, 211 | "line": 1, 212 | }, 213 | "type": "punctuation", 214 | "value": ";", 215 | }, 216 | ], 217 | }, 218 | Object { 219 | "next": Object { 220 | "column": 22, 221 | "cursor": 22, 222 | "line": 1, 223 | }, 224 | "start": Object { 225 | "column": 21, 226 | "cursor": 21, 227 | "line": 1, 228 | }, 229 | "type": "space", 230 | "value": " ", 231 | }, 232 | ], 233 | }, 234 | ], 235 | }, 236 | ], 237 | } 238 | `; 239 | -------------------------------------------------------------------------------- /lib/__tests__/__snapshots__/input-stream.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#position defaults the position to 0 1`] = ` 4 | Object { 5 | "column": 0, 6 | "cursor": 0, 7 | "line": 1, 8 | } 9 | `; 10 | 11 | exports[`returns an new InputStream 1`] = ` 12 | Object { 13 | "eof": [Function], 14 | "err": [Function], 15 | "next": [Function], 16 | "peek": [Function], 17 | "position": [Function], 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /lib/__tests__/__snapshots__/token-stream.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#next consumes returns and the next token 1`] = ` 4 | Object { 5 | "next": Object { 6 | "column": 5, 7 | "cursor": 5, 8 | "line": 1, 9 | }, 10 | "start": Object { 11 | "column": 0, 12 | "cursor": 0, 13 | "line": 1, 14 | }, 15 | "type": "identifier", 16 | "value": "hello", 17 | } 18 | `; 19 | 20 | exports[`#next consumes returns and the next token 2`] = ` 21 | Object { 22 | "next": Object { 23 | "column": 6, 24 | "cursor": 6, 25 | "line": 1, 26 | }, 27 | "start": Object { 28 | "column": 5, 29 | "cursor": 5, 30 | "line": 1, 31 | }, 32 | "type": "space", 33 | "value": " ", 34 | } 35 | `; 36 | 37 | exports[`#next tokens atkeyword works 1`] = ` 38 | Object { 39 | "next": Object { 40 | "column": 6, 41 | "cursor": 6, 42 | "line": 1, 43 | }, 44 | "start": Object { 45 | "column": 0, 46 | "cursor": 0, 47 | "line": 1, 48 | }, 49 | "type": "atkeyword", 50 | "value": "mixin", 51 | } 52 | `; 53 | 54 | exports[`#next tokens comment single comment 1`] = ` 55 | Object { 56 | "next": Object { 57 | "column": 8, 58 | "cursor": 8, 59 | "line": 1, 60 | }, 61 | "start": Object { 62 | "column": 0, 63 | "cursor": 0, 64 | "line": 1, 65 | }, 66 | "type": "comment_singleline", 67 | "value": " Hello", 68 | } 69 | `; 70 | 71 | exports[`#next tokens comment single comment 2`] = ` 72 | Object { 73 | "next": Object { 74 | "column": 18, 75 | "cursor": 18, 76 | "line": 1, 77 | }, 78 | "start": Object { 79 | "column": 0, 80 | "cursor": 0, 81 | "line": 1, 82 | }, 83 | "type": "comment_multiline", 84 | "value": "* Hello World ", 85 | } 86 | `; 87 | 88 | exports[`#next tokens hex 3 digit (trailing invalid) 1`] = ` 89 | Object { 90 | "next": Object { 91 | "column": 4, 92 | "cursor": 4, 93 | "line": 1, 94 | }, 95 | "start": Object { 96 | "column": 0, 97 | "cursor": 0, 98 | "line": 1, 99 | }, 100 | "type": "color_hex", 101 | "value": "FF0", 102 | } 103 | `; 104 | 105 | exports[`#next tokens hex 3 digit lowercase 1`] = ` 106 | Object { 107 | "next": Object { 108 | "column": 4, 109 | "cursor": 4, 110 | "line": 1, 111 | }, 112 | "start": Object { 113 | "column": 0, 114 | "cursor": 0, 115 | "line": 1, 116 | }, 117 | "type": "color_hex", 118 | "value": "ff0", 119 | } 120 | `; 121 | 122 | exports[`#next tokens hex 3 digit uppercase 1`] = ` 123 | Object { 124 | "next": Object { 125 | "column": 4, 126 | "cursor": 4, 127 | "line": 1, 128 | }, 129 | "start": Object { 130 | "column": 0, 131 | "cursor": 0, 132 | "line": 1, 133 | }, 134 | "type": "color_hex", 135 | "value": "FF0", 136 | } 137 | `; 138 | 139 | exports[`#next tokens hex 6 digit lowercase 1`] = ` 140 | Object { 141 | "next": Object { 142 | "column": 7, 143 | "cursor": 7, 144 | "line": 1, 145 | }, 146 | "start": Object { 147 | "column": 0, 148 | "cursor": 0, 149 | "line": 1, 150 | }, 151 | "type": "color_hex", 152 | "value": "ff0099", 153 | } 154 | `; 155 | 156 | exports[`#next tokens hex 6 digit numbers 1`] = ` 157 | Object { 158 | "next": Object { 159 | "column": 7, 160 | "cursor": 7, 161 | "line": 1, 162 | }, 163 | "start": Object { 164 | "column": 0, 165 | "cursor": 0, 166 | "line": 1, 167 | }, 168 | "type": "color_hex", 169 | "value": "000000", 170 | } 171 | `; 172 | 173 | exports[`#next tokens hex 6 digit uppercase 1`] = ` 174 | Object { 175 | "next": Object { 176 | "column": 7, 177 | "cursor": 7, 178 | "line": 1, 179 | }, 180 | "start": Object { 181 | "column": 0, 182 | "cursor": 0, 183 | "line": 1, 184 | }, 185 | "type": "color_hex", 186 | "value": "FF0099", 187 | } 188 | `; 189 | 190 | exports[`#next tokens identifier checks for valid starting characters 1`] = ` 191 | Object { 192 | "next": Object { 193 | "column": 6, 194 | "cursor": 6, 195 | "line": 1, 196 | }, 197 | "start": Object { 198 | "column": 0, 199 | "cursor": 0, 200 | "line": 1, 201 | }, 202 | "type": "identifier", 203 | "value": "_hello", 204 | } 205 | `; 206 | 207 | exports[`#next tokens identifier ignores invalid starting characters 1`] = ` 208 | Object { 209 | "next": Object { 210 | "column": 1, 211 | "cursor": 1, 212 | "line": 1, 213 | }, 214 | "start": Object { 215 | "column": 0, 216 | "cursor": 0, 217 | "line": 1, 218 | }, 219 | "type": "number", 220 | "value": "0", 221 | } 222 | `; 223 | 224 | exports[`#next tokens number float (leading decimal) 1`] = ` 225 | Object { 226 | "next": Object { 227 | "column": 2, 228 | "cursor": 2, 229 | "line": 1, 230 | }, 231 | "start": Object { 232 | "column": 0, 233 | "cursor": 0, 234 | "line": 1, 235 | }, 236 | "type": "number", 237 | "value": ".3", 238 | } 239 | `; 240 | 241 | exports[`#next tokens number float 1`] = ` 242 | Object { 243 | "next": Object { 244 | "column": 3, 245 | "cursor": 3, 246 | "line": 1, 247 | }, 248 | "start": Object { 249 | "column": 0, 250 | "cursor": 0, 251 | "line": 1, 252 | }, 253 | "type": "number", 254 | "value": "3.0", 255 | } 256 | `; 257 | 258 | exports[`#next tokens number integer 1`] = ` 259 | Object { 260 | "next": Object { 261 | "column": 1, 262 | "cursor": 1, 263 | "line": 1, 264 | }, 265 | "start": Object { 266 | "column": 0, 267 | "cursor": 0, 268 | "line": 1, 269 | }, 270 | "type": "number", 271 | "value": "3", 272 | } 273 | `; 274 | 275 | exports[`#next tokens operator + 1`] = ` 276 | Object { 277 | "next": Object { 278 | "column": 1, 279 | "cursor": 1, 280 | "line": 1, 281 | }, 282 | "start": Object { 283 | "column": 0, 284 | "cursor": 0, 285 | "line": 1, 286 | }, 287 | "type": "operator", 288 | "value": "+", 289 | } 290 | `; 291 | 292 | exports[`#next tokens operator non-repeatable 1`] = ` 293 | Object { 294 | "next": Object { 295 | "column": 1, 296 | "cursor": 1, 297 | "line": 1, 298 | }, 299 | "start": Object { 300 | "column": 0, 301 | "cursor": 0, 302 | "line": 1, 303 | }, 304 | "type": "operator", 305 | "value": "+", 306 | } 307 | `; 308 | 309 | exports[`#next tokens operator repeatable 1`] = ` 310 | Object { 311 | "next": Object { 312 | "column": 2, 313 | "cursor": 2, 314 | "line": 1, 315 | }, 316 | "start": Object { 317 | "column": 0, 318 | "cursor": 0, 319 | "line": 1, 320 | }, 321 | "type": "operator", 322 | "value": "&&", 323 | } 324 | `; 325 | 326 | exports[`#next tokens operator repeatable followed by non-repeatable 1`] = ` 327 | Object { 328 | "next": Object { 329 | "column": 1, 330 | "cursor": 1, 331 | "line": 1, 332 | }, 333 | "start": Object { 334 | "column": 0, 335 | "cursor": 0, 336 | "line": 1, 337 | }, 338 | "type": "operator", 339 | "value": "&", 340 | } 341 | `; 342 | 343 | exports[`#next tokens puctuation { 1`] = ` 344 | Object { 345 | "next": Object { 346 | "column": 1, 347 | "cursor": 1, 348 | "line": 1, 349 | }, 350 | "start": Object { 351 | "column": 0, 352 | "cursor": 0, 353 | "line": 1, 354 | }, 355 | "type": "punctuation", 356 | "value": "{", 357 | } 358 | `; 359 | 360 | exports[`#next tokens sink 1 1`] = ` 361 | Array [ 362 | Object { 363 | "next": Object { 364 | "column": 1, 365 | "cursor": 1, 366 | "line": 1, 367 | }, 368 | "start": Object { 369 | "column": 0, 370 | "cursor": 0, 371 | "line": 1, 372 | }, 373 | "type": "punctuation", 374 | "value": "(", 375 | }, 376 | Object { 377 | "next": Object { 378 | "column": 5, 379 | "cursor": 5, 380 | "line": 1, 381 | }, 382 | "start": Object { 383 | "column": 1, 384 | "cursor": 1, 385 | "line": 1, 386 | }, 387 | "type": "variable", 388 | "value": "var", 389 | }, 390 | Object { 391 | "next": Object { 392 | "column": 6, 393 | "cursor": 6, 394 | "line": 1, 395 | }, 396 | "start": Object { 397 | "column": 5, 398 | "cursor": 5, 399 | "line": 1, 400 | }, 401 | "type": "punctuation", 402 | "value": ")", 403 | }, 404 | ] 405 | `; 406 | 407 | exports[`#next tokens sink 2 1`] = ` 408 | Array [ 409 | Object { 410 | "next": Object { 411 | "column": 9, 412 | "cursor": 9, 413 | "line": 1, 414 | }, 415 | "start": Object { 416 | "column": 0, 417 | "cursor": 0, 418 | "line": 1, 419 | }, 420 | "type": "comment_singleline", 421 | "value": " ($var)", 422 | }, 423 | Object { 424 | "next": Object { 425 | "column": 0, 426 | "cursor": 10, 427 | "line": 2, 428 | }, 429 | "start": Object { 430 | "column": 9, 431 | "cursor": 9, 432 | "line": 1, 433 | }, 434 | "type": "space", 435 | "value": " 436 | ", 437 | }, 438 | Object { 439 | "next": Object { 440 | "column": 6, 441 | "cursor": 16, 442 | "line": 2, 443 | }, 444 | "start": Object { 445 | "column": 0, 446 | "cursor": 10, 447 | "line": 2, 448 | }, 449 | "type": "atkeyword", 450 | "value": "mixin", 451 | }, 452 | Object { 453 | "next": Object { 454 | "column": 7, 455 | "cursor": 17, 456 | "line": 2, 457 | }, 458 | "start": Object { 459 | "column": 6, 460 | "cursor": 16, 461 | "line": 2, 462 | }, 463 | "type": "space", 464 | "value": " ", 465 | }, 466 | Object { 467 | "next": Object { 468 | "column": 14, 469 | "cursor": 24, 470 | "line": 2, 471 | }, 472 | "start": Object { 473 | "column": 7, 474 | "cursor": 17, 475 | "line": 2, 476 | }, 477 | "type": "identifier", 478 | "value": "myMixin", 479 | }, 480 | ] 481 | `; 482 | 483 | exports[`#next tokens space carriage return character 1`] = ` 484 | Object { 485 | "next": Object { 486 | "column": 2, 487 | "cursor": 4, 488 | "line": 2, 489 | }, 490 | "start": Object { 491 | "column": 0, 492 | "cursor": 0, 493 | "line": 1, 494 | }, 495 | "type": "space", 496 | "value": " 497 | ", 498 | } 499 | `; 500 | 501 | exports[`#next tokens space multiple spaces 1`] = ` 502 | Object { 503 | "next": Object { 504 | "column": 4, 505 | "cursor": 4, 506 | "line": 1, 507 | }, 508 | "start": Object { 509 | "column": 0, 510 | "cursor": 0, 511 | "line": 1, 512 | }, 513 | "type": "space", 514 | "value": " ", 515 | } 516 | `; 517 | 518 | exports[`#next tokens space single space 1`] = ` 519 | Object { 520 | "next": Object { 521 | "column": 1, 522 | "cursor": 1, 523 | "line": 1, 524 | }, 525 | "start": Object { 526 | "column": 0, 527 | "cursor": 0, 528 | "line": 1, 529 | }, 530 | "type": "space", 531 | "value": " ", 532 | } 533 | `; 534 | 535 | exports[`#next tokens space whitespace characters 1`] = ` 536 | Object { 537 | "next": Object { 538 | "column": 3, 539 | "cursor": 5, 540 | "line": 3, 541 | }, 542 | "start": Object { 543 | "column": 0, 544 | "cursor": 0, 545 | "line": 1, 546 | }, 547 | "type": "space", 548 | "value": " 549 | 550 | ", 551 | } 552 | `; 553 | 554 | exports[`#next tokens string double quotes 1`] = ` 555 | Object { 556 | "next": Object { 557 | "column": 7, 558 | "cursor": 7, 559 | "line": 1, 560 | }, 561 | "start": Object { 562 | "column": 0, 563 | "cursor": 0, 564 | "line": 1, 565 | }, 566 | "type": "string_double", 567 | "value": "hello", 568 | } 569 | `; 570 | 571 | exports[`#next tokens string escaped characters 1`] = ` 572 | Object { 573 | "next": Object { 574 | "column": 17, 575 | "cursor": 17, 576 | "line": 1, 577 | }, 578 | "start": Object { 579 | "column": 0, 580 | "cursor": 0, 581 | "line": 1, 582 | }, 583 | "type": "string_double", 584 | "value": "hello \\\\\\"world\\\\\\"", 585 | } 586 | `; 587 | 588 | exports[`#next tokens string preserves escape characters 1`] = ` 589 | Array [ 590 | Object { 591 | "next": Object { 592 | "column": 5, 593 | "cursor": 5, 594 | "line": 1, 595 | }, 596 | "start": Object { 597 | "column": 0, 598 | "cursor": 0, 599 | "line": 1, 600 | }, 601 | "type": "identifier", 602 | "value": "token", 603 | }, 604 | Object { 605 | "next": Object { 606 | "column": 6, 607 | "cursor": 6, 608 | "line": 1, 609 | }, 610 | "start": Object { 611 | "column": 5, 612 | "cursor": 5, 613 | "line": 1, 614 | }, 615 | "type": "punctuation", 616 | "value": "(", 617 | }, 618 | Object { 619 | "next": Object { 620 | "column": 8, 621 | "cursor": 8, 622 | "line": 1, 623 | }, 624 | "start": Object { 625 | "column": 6, 626 | "cursor": 6, 627 | "line": 1, 628 | }, 629 | "type": "string_single", 630 | "value": "", 631 | }, 632 | Object { 633 | "next": Object { 634 | "column": 9, 635 | "cursor": 9, 636 | "line": 1, 637 | }, 638 | "start": Object { 639 | "column": 8, 640 | "cursor": 8, 641 | "line": 1, 642 | }, 643 | "type": "operator", 644 | "value": "+", 645 | }, 646 | Object { 647 | "next": Object { 648 | "column": 14, 649 | "cursor": 14, 650 | "line": 1, 651 | }, 652 | "start": Object { 653 | "column": 9, 654 | "cursor": 9, 655 | "line": 1, 656 | }, 657 | "type": "identifier", 658 | "value": "myVar", 659 | }, 660 | Object { 661 | "next": Object { 662 | "column": 15, 663 | "cursor": 15, 664 | "line": 1, 665 | }, 666 | "start": Object { 667 | "column": 14, 668 | "cursor": 14, 669 | "line": 1, 670 | }, 671 | "type": "operator", 672 | "value": "+", 673 | }, 674 | Object { 675 | "next": Object { 676 | "column": 32, 677 | "cursor": 32, 678 | "line": 1, 679 | }, 680 | "start": Object { 681 | "column": 15, 682 | "cursor": 15, 683 | "line": 1, 684 | }, 685 | "type": "string_single", 686 | "value": "font(\\\\'world\\\\')", 687 | }, 688 | Object { 689 | "next": Object { 690 | "column": 33, 691 | "cursor": 33, 692 | "line": 1, 693 | }, 694 | "start": Object { 695 | "column": 32, 696 | "cursor": 32, 697 | "line": 1, 698 | }, 699 | "type": "punctuation", 700 | "value": ")", 701 | }, 702 | ] 703 | `; 704 | 705 | exports[`#next tokens string single quotes 1`] = ` 706 | Object { 707 | "next": Object { 708 | "column": 7, 709 | "cursor": 7, 710 | "line": 1, 711 | }, 712 | "start": Object { 713 | "column": 0, 714 | "cursor": 0, 715 | "line": 1, 716 | }, 717 | "type": "string_single", 718 | "value": "hello", 719 | } 720 | `; 721 | 722 | exports[`#next tokens variable works 1`] = ` 723 | Object { 724 | "next": Object { 725 | "column": 5, 726 | "cursor": 5, 727 | "line": 1, 728 | }, 729 | "start": Object { 730 | "column": 0, 731 | "cursor": 0, 732 | "line": 1, 733 | }, 734 | "type": "variable", 735 | "value": "size", 736 | } 737 | `; 738 | 739 | exports[`#peek returns the current token 1`] = ` 740 | Object { 741 | "next": Object { 742 | "column": 5, 743 | "cursor": 5, 744 | "line": 1, 745 | }, 746 | "start": Object { 747 | "column": 0, 748 | "cursor": 0, 749 | "line": 1, 750 | }, 751 | "type": "identifier", 752 | "value": "hello", 753 | } 754 | `; 755 | 756 | exports[`#peek returns the current token with an offset 1`] = ` 757 | Object { 758 | "next": Object { 759 | "column": 6, 760 | "cursor": 6, 761 | "line": 1, 762 | }, 763 | "start": Object { 764 | "column": 5, 765 | "cursor": 5, 766 | "line": 1, 767 | }, 768 | "type": "space", 769 | "value": " ", 770 | } 771 | `; 772 | 773 | exports[`returns an TokenStream 1`] = ` 774 | Object { 775 | "all": [Function], 776 | "eof": [Function], 777 | "err": [Function], 778 | "next": [Function], 779 | "peek": [Function], 780 | } 781 | `; 782 | -------------------------------------------------------------------------------- /lib/__tests__/index.spec.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | /* eslint-env jest */ 5 | 6 | const { parse, stringify } = require('../') 7 | 8 | it('parse', () => { 9 | expect(parse('.a { background: red; }')).toMatchSnapshot() 10 | }) 11 | 12 | it('parse', () => { 13 | const scss = '.a { background: red; }' 14 | expect(stringify(parse(scss))).toEqual(scss) 15 | }) 16 | -------------------------------------------------------------------------------- /lib/__tests__/input-stream.spec.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | /* eslint-env jest */ 5 | 6 | const createInputStream = require('../input-stream') 7 | 8 | it('returns an new InputStream', () => { 9 | const i = createInputStream() 10 | expect(i).toMatchSnapshot() 11 | }) 12 | 13 | describe('#position', () => { 14 | it('defaults the position to 0', () => { 15 | const p = createInputStream().position() 16 | expect(Object.isFrozen(p)).toBe(true) 17 | expect(p).toMatchSnapshot() 18 | }) 19 | }) 20 | 21 | describe('#peek', () => { 22 | it('returns the current character', () => { 23 | const i = createInputStream('hello') 24 | expect(i.peek()).toEqual('h') 25 | }) 26 | it('returns the current character with an offset', () => { 27 | const i = createInputStream('hello') 28 | expect(i.peek(1)).toEqual('e') 29 | }) 30 | }) 31 | 32 | describe('#next', () => { 33 | it('consumes and returns the next character', () => { 34 | const i = createInputStream('hello') 35 | expect(i.next()).toEqual('h') 36 | }) 37 | it('advances the cursor', () => { 38 | const i = createInputStream('hello') 39 | expect(i.next()).toEqual('h') 40 | expect(i.position().cursor).toEqual(1) 41 | expect(i.position().line).toEqual(1) 42 | expect(i.position().column).toEqual(1) 43 | }) 44 | it('advances the line', () => { 45 | const i = createInputStream('h\ni') 46 | expect(i.next()).toEqual('h') 47 | expect(i.next()).toEqual('\n') 48 | expect(i.next()).toEqual('i') 49 | expect(i.position().cursor).toEqual(3) 50 | expect(i.position().line).toEqual(2) 51 | expect(i.position().column).toEqual(1) 52 | }) 53 | }) 54 | 55 | describe('#eof', () => { 56 | it('returns false if there are more characters', () => { 57 | const i = createInputStream('hello') 58 | expect(i.eof()).toEqual(false) 59 | }) 60 | it('returns true if there are no more characters', () => { 61 | const i = createInputStream('hi') 62 | expect(i.eof()).toEqual(false) 63 | i.next() 64 | i.next() 65 | expect(i.eof()).toEqual(true) 66 | }) 67 | }) 68 | 69 | describe('#err', () => { 70 | it('throws an error', () => { 71 | const i = createInputStream('hello') 72 | i.next() 73 | i.next() 74 | expect(() => { 75 | i.err('Whoops') 76 | }).toThrow(/Whoops \(1:2\)/) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /lib/__tests__/parse.spec.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | /* eslint-env jest */ 5 | 6 | const createInputStream = require('../input-stream') 7 | const createTokenStream = require('../token-stream') 8 | const parse = require('../parse') 9 | 10 | const createAST = (input) => 11 | parse(createTokenStream(createInputStream(input))) 12 | 13 | it('requires an InputStream', () => { 14 | expect(() => { 15 | parse() 16 | }).toThrow(/TokenStream/) 17 | }) 18 | 19 | it('returns an AST', () => { 20 | const ast = createAST('a') 21 | expect(ast).toMatchSnapshot() 22 | }) 23 | 24 | describe('function', () => { 25 | it('no args', () => { 26 | const ast = createAST('fn()') 27 | expect(ast).toMatchSnapshot() 28 | }) 29 | it('1 arg', () => { 30 | const ast = createAST('fn($a)') 31 | expect(ast).toMatchSnapshot() 32 | }) 33 | it('2 args', () => { 34 | const ast = createAST('fn($a, $b)') 35 | expect(ast).toMatchSnapshot() 36 | }) 37 | it('function as the caller', () => { 38 | const ast = createAST('hello(world($a))') 39 | expect(ast).toMatchSnapshot() 40 | }) 41 | it('interpolation as the caller', () => { 42 | const ast = createAST('#{hello}($a)') 43 | expect(ast).toMatchSnapshot() 44 | }) 45 | }) 46 | describe('interpolation', () => { 47 | it('1 var', () => { 48 | const ast = createAST('#{$a}') 49 | expect(ast).toMatchSnapshot() 50 | }) 51 | it('nested', () => { 52 | const ast = createAST('#{#{$a}}') 53 | expect(ast).toMatchSnapshot() 54 | }) 55 | }) 56 | describe('parentheses', () => { 57 | it('1 var', () => { 58 | const ast = createAST('($a)') 59 | expect(ast).toMatchSnapshot() 60 | }) 61 | it('nested', () => { 62 | const ast = createAST('(($a))') 63 | expect(ast).toMatchSnapshot() 64 | }) 65 | }) 66 | describe('attribute', () => { 67 | it('1 var', () => { 68 | const ast = createAST('[$a]') 69 | expect(ast).toMatchSnapshot() 70 | }) 71 | it('nested', () => { 72 | const ast = createAST('[[$a]]') 73 | expect(ast).toMatchSnapshot() 74 | }) 75 | }) 76 | describe('class', () => { 77 | it('identifier', () => { 78 | const ast = createAST('.hello') 79 | expect(ast).toMatchSnapshot() 80 | }) 81 | it('identifier + interpolation', () => { 82 | const ast = createAST('.hello-#{$a}') 83 | expect(ast).toMatchSnapshot() 84 | }) 85 | it('identifier + interpolation + identifier', () => { 86 | const ast = createAST('.hello-#{$a}-world') 87 | expect(ast).toMatchSnapshot() 88 | }) 89 | it('identifier + id', () => { 90 | const ast = createAST('.hello#world') 91 | expect(ast).toMatchSnapshot() 92 | }) 93 | }) 94 | describe('id', () => { 95 | it('identifier', () => { 96 | const ast = createAST('#hello') 97 | expect(ast).toMatchSnapshot() 98 | }) 99 | it('identifier + interpolation', () => { 100 | const ast = createAST('#hello-#{$a}') 101 | expect(ast).toMatchSnapshot() 102 | }) 103 | it('interpolation', () => { 104 | const ast = createAST('##{$a}') 105 | expect(ast).toMatchSnapshot() 106 | }) 107 | }) 108 | describe('declaration', () => { 109 | it('simple', () => { 110 | const ast = createAST('$color: red;') 111 | expect(ast).toMatchSnapshot() 112 | }) 113 | it('complex', () => { 114 | const ast = createAST('$map: ("foo": "bar", "hello": rgba($a));') 115 | expect(ast).toMatchSnapshot() 116 | }) 117 | it('trailing', () => { 118 | const ast = createAST('.a { padding: 1px { top: 2px; } }') 119 | expect(ast).toMatchSnapshot() 120 | }) 121 | it('trailing 2', () => { 122 | const ast = createAST('padding: 1px { top: 2px; }') 123 | expect(ast).toMatchSnapshot() 124 | }) 125 | }) 126 | describe('rule', () => { 127 | it('1 selector', () => { 128 | const ast = createAST('.a {}') 129 | expect(ast).toMatchSnapshot() 130 | }) 131 | it('1 selector 1 declaration', () => { 132 | const ast = createAST('.a { color: red; }') 133 | expect(ast).toMatchSnapshot() 134 | }) 135 | it('1 selector 1 declaration 1 nested selector 1 declaration', () => { 136 | const ast = createAST('.a { color: red; .b { color: blue; } }') 137 | expect(ast).toMatchSnapshot() 138 | }) 139 | it('trailing ";"', () => { 140 | const ast = createAST('.a {}; .b {}') 141 | expect(ast).toMatchSnapshot() 142 | }) 143 | it('1 pseudo class', () => { 144 | const ast = createAST(':hover {}') 145 | expect(ast).toMatchSnapshot() 146 | }) 147 | it('1 class 2 pseudo classes', () => { 148 | const ast = createAST('.a:hover:active {}') 149 | expect(ast).toMatchSnapshot() 150 | }) 151 | it('1 class 2 pseudo classes 1 interpolation', () => { 152 | const ast = createAST('.a:hover:#{active} {}') 153 | expect(ast).toMatchSnapshot() 154 | }) 155 | it('2 classes 2 pseudo classes', () => { 156 | const ast = createAST('.a:hover, .a:active {}') 157 | expect(ast).toMatchSnapshot() 158 | }) 159 | it('2 classes 2 pseudo classes', () => { 160 | const ast = createAST('li:hover[data-foo=bar] {}') 161 | expect(ast).toMatchSnapshot() 162 | }) 163 | it('nested pseudo classes', () => { 164 | const ast = createAST('li:a(:b) {}') 165 | expect(ast).toMatchSnapshot() 166 | }) 167 | it('nested pseudo classes (with identifier)', () => { 168 | const ast = createAST('li:a(item:b) {}') 169 | expect(ast).toMatchSnapshot() 170 | }) 171 | it('1 pseudo class 1 declaration (no space)', () => { 172 | const ast = createAST('li:hover { color:red; }') 173 | expect(ast).toMatchSnapshot() 174 | }) 175 | it('1 pseudo class 1 declaration 1 nested declaration', () => { 176 | const ast = createAST('li:hover { color: red { alt: blue; } }') 177 | expect(ast).toMatchSnapshot() 178 | }) 179 | it('1 pseudo class 1 declaration (no space) 1 nested declaration', () => { 180 | const ast = createAST('li:hover { color:red { alt:blue; } }') 181 | expect(ast).toMatchSnapshot() 182 | }) 183 | }) 184 | describe('atrule', () => { 185 | it('include 0 args', () => { 186 | const ast = createAST('@include myMixin;') 187 | expect(ast).toMatchSnapshot() 188 | }) 189 | it('include 1 required arg', () => { 190 | const ast = createAST('@include myMixin($a);') 191 | expect(ast).toMatchSnapshot() 192 | }) 193 | it('include 1 required arg 1 optional arg', () => { 194 | const ast = createAST('@include myMixin($a, $b: null);') 195 | expect(ast).toMatchSnapshot() 196 | }) 197 | it('include 1 required arg 1 optional arg (complex)', () => { 198 | const ast = createAST('@include myMixin($a, $b: rgba($c) + 1);') 199 | expect(ast).toMatchSnapshot() 200 | }) 201 | it('mixin 0 args', () => { 202 | const ast = createAST('@mixin myMixin { }') 203 | expect(ast).toMatchSnapshot() 204 | }) 205 | it('mixin 0 args 1 declaration', () => { 206 | const ast = createAST('@mixin myMixin { color: red; }') 207 | expect(ast).toMatchSnapshot() 208 | }) 209 | it('mixin 1 required arg 1 declaration', () => { 210 | const ast = createAST('@mixin myMixin($a) { color: red; }') 211 | expect(ast).toMatchSnapshot() 212 | }) 213 | it('mixin 1 required arg 1 optional arg 1 declaration', () => { 214 | const ast = createAST('@mixin myMixin($a, $b: null) { color: red; }') 215 | expect(ast).toMatchSnapshot() 216 | }) 217 | }) 218 | describe('sink', () => { 219 | it('works', () => { 220 | const ast = createAST(` 221 | $base-font-family: 'ProximaNova' !default; 222 | @mixin font-face($font-family, $file-name, $baseurl, $weight: 500, $style: normal ){ 223 | @font-face { 224 | font: { 225 | family: $font-family; 226 | weight: $weight; 227 | style: $style; 228 | } 229 | src: url( $baseurl + $file-name + '.eot'); 230 | src: url( $baseurl + $file-name + '.eot?#iefix') format('embedded-opentype'); 231 | src: url( $baseurl + $file-name + '.woff') format('woff'), 232 | url( $baseurl + $file-name + '.woff2') format('woff2'), 233 | url( $baseurl + $file-name + '.ttf') format('truetype'), 234 | url( $baseurl + $file-name + '.svg' + '#' + $file-name) format('svg'); 235 | } 236 | } 237 | `) 238 | expect(ast).toMatchSnapshot() 239 | }) 240 | }) 241 | -------------------------------------------------------------------------------- /lib/__tests__/stringify.spec.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | /* eslint-env jest */ 5 | 6 | const createInputStream = require('../input-stream') 7 | const createTokenStream = require('../token-stream') 8 | const parse = require('../parse') 9 | const stringify = require('../stringify') 10 | 11 | const createAST = (input) => 12 | parse(createTokenStream(createInputStream(input))) 13 | 14 | it('class', () => { 15 | const css = '.a {}' 16 | const ast = createAST(css) 17 | expect(stringify(ast)).toEqual(css) 18 | }) 19 | 20 | it('atkeyword', () => { 21 | const css = '@mixin myMixin {}' 22 | const ast = createAST(css) 23 | expect(stringify(ast)).toEqual(css) 24 | }) 25 | 26 | it('pseudo_class', () => { 27 | const css = '.a:hover:active:#{focus} {}' 28 | const ast = createAST(css) 29 | expect(stringify(ast)).toEqual(css) 30 | }) 31 | 32 | it('sink 1', () => { 33 | const css = ` 34 | .a { 35 | .b { 36 | color: red; 37 | } 38 | } 39 | ` 40 | const ast = createAST(css) 41 | expect(stringify(ast)).toEqual(css) 42 | }) 43 | 44 | it('sink 2', () => { 45 | const css = ` 46 | /// Casts a string into a number (integer only) 47 | /// 48 | /// @param {String} $value - Value to be parsed 49 | /// 50 | /// @return {Number} 51 | /// @author @HugoGiraudel - Simplified by @kaelig to only convert unsigned integers 52 | /// @see http://hugogiraudel.com/2014/01/15/sass-string-to-number/ 53 | /// @access private 54 | @function _d-to-number($value) { 55 | $result: 0; 56 | $digits: 0; 57 | $numbers: ('0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9); 58 | 59 | @for $i from 1 through str-length($value) { 60 | $character: str-slice($value, $i, $i); 61 | 62 | @if $digits == 0 { 63 | $result: $result * 10 + map-get($numbers, $character); 64 | } @else { 65 | $digits: $digits * 10; 66 | $result: $result + map-get($numbers, $character) / $digits; 67 | } 68 | } 69 | 70 | @return $result; 71 | } 72 | ` 73 | const ast = createAST(css) 74 | expect(stringify(ast)).toEqual(css) 75 | }) 76 | 77 | it('sink 3', () => { 78 | const css = ` 79 | *, 80 | *:before, 81 | *:after { 82 | box-sizing: border-box; 83 | } 84 | ` 85 | const ast = createAST(css) 86 | expect(stringify(ast)).toEqual(css) 87 | }) 88 | 89 | it('sink 4', () => { 90 | const css = ` 91 | li:hover:active { 92 | color:red; 93 | } 94 | ` 95 | const ast = createAST(css) 96 | expect(stringify(ast)).toEqual(css) 97 | }) 98 | -------------------------------------------------------------------------------- /lib/__tests__/token-stream.spec.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | /* eslint-env jest */ 5 | 6 | const createInputStream = require('../input-stream') 7 | const createTokenStream = require('../token-stream') 8 | 9 | it('requires an InputStream', () => { 10 | expect(() => { 11 | createTokenStream() 12 | }).toThrow(/InputStream/) 13 | }) 14 | 15 | it('returns an TokenStream', () => { 16 | const t = createTokenStream(createInputStream()) 17 | expect(t).toMatchSnapshot() 18 | }) 19 | 20 | describe('#all', () => { 21 | it('tokenizes all characters in the InputStream', () => { 22 | const t = createTokenStream(createInputStream('hello')) 23 | expect(t.all().length).toEqual(1) 24 | expect(t.eof()).toEqual(true) 25 | }) 26 | }) 27 | 28 | describe('#peek', () => { 29 | it('returns the current token', () => { 30 | const t = createTokenStream(createInputStream('hello')) 31 | expect(t.peek()).toMatchSnapshot() 32 | }) 33 | it('returns the current token with an offset', () => { 34 | const t = createTokenStream(createInputStream('hello world')) 35 | expect(t.peek(1)).toMatchSnapshot() 36 | }) 37 | }) 38 | 39 | describe('#next', () => { 40 | it('consumes returns and the next token', () => { 41 | const t = createTokenStream(createInputStream('hello world')) 42 | expect(t.next()).toMatchSnapshot() 43 | expect(t.peek()).toMatchSnapshot() 44 | }) 45 | describe('tokens', () => { 46 | describe('space', () => { 47 | it('single space', () => { 48 | const t = createTokenStream(createInputStream(' ')) 49 | expect(t.next()).toMatchSnapshot() 50 | }) 51 | it('multiple spaces', () => { 52 | const t = createTokenStream(createInputStream(' hello')) 53 | expect(t.next()).toMatchSnapshot() 54 | }) 55 | it('whitespace characters', () => { 56 | const t = createTokenStream(createInputStream('\n\n\t hello')) 57 | expect(t.next()).toMatchSnapshot() 58 | }) 59 | it('carriage return character', () => { 60 | const t = createTokenStream(createInputStream('\r\n hello')) 61 | expect(t.next()).toMatchSnapshot() 62 | }) 63 | }) 64 | describe('comment', () => { 65 | it('single comment', () => { 66 | const t = createTokenStream(createInputStream('// Hello\nWorld')) 67 | expect(t.next()).toMatchSnapshot() 68 | }) 69 | it('single comment', () => { 70 | const t = createTokenStream(createInputStream('/** Hello World */')) 71 | expect(t.next()).toMatchSnapshot() 72 | }) 73 | }) 74 | describe('number', () => { 75 | it('integer', () => { 76 | const t = createTokenStream(createInputStream('3')) 77 | expect(t.next()).toMatchSnapshot() 78 | }) 79 | it('float', () => { 80 | const t = createTokenStream(createInputStream('3.0')) 81 | expect(t.next()).toMatchSnapshot() 82 | }) 83 | it('float (leading decimal)', () => { 84 | const t = createTokenStream(createInputStream('.3')) 85 | expect(t.next()).toMatchSnapshot() 86 | }) 87 | }) 88 | describe('hex', () => { 89 | it('6 digit lowercase', () => { 90 | const t = createTokenStream(createInputStream('#ff0099')) 91 | expect(t.next()).toMatchSnapshot() 92 | }) 93 | it('6 digit uppercase', () => { 94 | const t = createTokenStream(createInputStream('#FF0099')) 95 | expect(t.next()).toMatchSnapshot() 96 | }) 97 | it('3 digit lowercase', () => { 98 | const t = createTokenStream(createInputStream('#ff0')) 99 | expect(t.next()).toMatchSnapshot() 100 | }) 101 | it('3 digit uppercase', () => { 102 | const t = createTokenStream(createInputStream('#FF0')) 103 | expect(t.next()).toMatchSnapshot() 104 | }) 105 | it('3 digit (trailing invalid)', () => { 106 | const t = createTokenStream(createInputStream('#FF0;')) 107 | expect(t.next()).toMatchSnapshot() 108 | }) 109 | it('6 digit numbers', () => { 110 | const t = createTokenStream(createInputStream('#000000')) 111 | expect(t.next()).toMatchSnapshot() 112 | }) 113 | }) 114 | describe('atkeyword', () => { 115 | it('works', () => { 116 | const t = createTokenStream(createInputStream('@mixin')) 117 | expect(t.next()).toMatchSnapshot() 118 | }) 119 | }) 120 | describe('puctuation', () => { 121 | it('{', () => { 122 | const t = createTokenStream(createInputStream('{')) 123 | expect(t.next()).toMatchSnapshot() 124 | }) 125 | }) 126 | describe('operator', () => { 127 | it('+', () => { 128 | const t = createTokenStream(createInputStream('+')) 129 | expect(t.next()).toMatchSnapshot() 130 | }) 131 | it('repeatable', () => { 132 | const t = createTokenStream(createInputStream('&&')) 133 | expect(t.next()).toMatchSnapshot() 134 | }) 135 | it('non-repeatable', () => { 136 | const t = createTokenStream(createInputStream('++')) 137 | expect(t.next()).toMatchSnapshot() 138 | }) 139 | it('repeatable followed by non-repeatable', () => { 140 | const t = createTokenStream(createInputStream('&++')) 141 | expect(t.next()).toMatchSnapshot() 142 | }) 143 | }) 144 | describe('identifier', () => { 145 | it('checks for valid starting characters', () => { 146 | const t = createTokenStream(createInputStream('_hello world')) 147 | expect(t.next()).toMatchSnapshot() 148 | }) 149 | it('ignores invalid starting characters', () => { 150 | const t = createTokenStream(createInputStream('0hello world')) 151 | expect(t.next()).toMatchSnapshot() 152 | }) 153 | }) 154 | describe('string', () => { 155 | it('single quotes', () => { 156 | const t = createTokenStream(createInputStream('\'hello\'')) 157 | expect(t.next()).toMatchSnapshot() 158 | }) 159 | it('double quotes', () => { 160 | const t = createTokenStream(createInputStream('"hello"')) 161 | expect(t.next()).toMatchSnapshot() 162 | }) 163 | it('escaped characters', () => { 164 | const t = createTokenStream(createInputStream('"hello \\"world\\""')) 165 | expect(t.next()).toMatchSnapshot() 166 | }) 167 | it('preserves escape characters', () => { 168 | const t = createTokenStream(createInputStream('token(\'\'+myVar+\'font(\\\'world\\\')\')')) 169 | expect(t.all()).toMatchSnapshot() 170 | }) 171 | }) 172 | describe('variable', () => { 173 | it('works', () => { 174 | const t = createTokenStream(createInputStream('$size')) 175 | expect(t.next()).toMatchSnapshot() 176 | }) 177 | }) 178 | describe('sink', () => { 179 | it('1', () => { 180 | const t = createTokenStream(createInputStream('($var)')) 181 | expect(t.all()).toMatchSnapshot() 182 | }) 183 | it('2', () => { 184 | const t = createTokenStream(createInputStream('// ($var)\n@mixin myMixin')) 185 | expect(t.all()).toMatchSnapshot() 186 | }) 187 | }) 188 | }) 189 | }) 190 | 191 | describe('#eof', () => { 192 | it('returns false if there are more tokens', () => { 193 | const t = createTokenStream(createInputStream('hello')) 194 | expect(t.eof()).toEqual(false) 195 | }) 196 | it('returns true if there are no more tokens', () => { 197 | const t = createTokenStream(createInputStream('hello world')) 198 | expect(t.eof()).toEqual(false) 199 | t.next() 200 | t.next() 201 | t.next() 202 | expect(t.eof()).toEqual(true) 203 | }) 204 | }) 205 | 206 | describe('#err', () => { 207 | it('throws an error', () => { 208 | const t = createTokenStream(createInputStream('hello world')) 209 | t.next() 210 | t.next() 211 | expect(() => { 212 | t.err('Whoops') 213 | }).toThrow(/Whoops \(1:6\)/) 214 | }) 215 | }) 216 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2016, salesforce.com, inc. All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | */ 11 | 12 | const createInputStream = require('./input-stream') 13 | const createTokenStream = require('./token-stream') 14 | 15 | const _parse = require('./parse') 16 | const _stringify = require('./stringify') 17 | 18 | /** 19 | * Parse the proivded input as a @{link Node} 20 | * 21 | * @param {string} css 22 | * @returns {Node} 23 | */ 24 | const parse = css => _parse(createTokenStream(createInputStream(css))) 25 | 26 | /** 27 | * Convert a @{link Node} back into a stirng 28 | * 29 | * @param {Node} node 30 | * @returns {string} 31 | */ 32 | const stringify = node => _stringify(node) 33 | 34 | module.exports = { 35 | parse, 36 | stringify 37 | } 38 | -------------------------------------------------------------------------------- /lib/input-stream.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2016, salesforce.com, inc. All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | */ 11 | 12 | /* eslint-disable camelcase */ 13 | 14 | const _ = require('lodash') 15 | 16 | /* 17 | * @typedef {object} InputStream~Position 18 | * @property {number} cursor 19 | * @property {number} line 20 | * @property {number} column 21 | */ 22 | 23 | /** 24 | * Yield characters from a string 25 | * 26 | * @protected 27 | * @class 28 | */ 29 | class InputStream { 30 | /** 31 | * Create a new InputStream 32 | * 33 | * @param {string} input 34 | */ 35 | constructor (input) { 36 | this.input = input 37 | this.cursor = 0 38 | this.line = 1 39 | this.column = 0 40 | } 41 | 42 | /** 43 | * Return an object that contains the currrent cursor, line, and column 44 | * 45 | * @public 46 | * @returns {InputStream~Position} 47 | */ 48 | position () { 49 | return Object.freeze({ 50 | cursor: this.cursor, 51 | line: this.line, 52 | column: this.column 53 | }) 54 | } 55 | 56 | /** 57 | * Return the current character with an optional offset 58 | * 59 | * @public 60 | * @param {number} offset 61 | * @returns {string} 62 | */ 63 | peek (offset) { 64 | const cursor = _.isInteger(offset) 65 | ? this.cursor + offset : this.cursor 66 | return this.input.charAt(cursor) 67 | } 68 | 69 | /** 70 | * Return the current character and advance the cursor 71 | * 72 | * @public 73 | * @returns {string} 74 | */ 75 | next () { 76 | const c = this.input.charAt(this.cursor++) 77 | if (c === '\n') { 78 | this.line++ 79 | this.column = 0 80 | } else { 81 | this.column++ 82 | } 83 | return c 84 | } 85 | 86 | /** 87 | * Return true if the stream has reached the end 88 | * 89 | * @public 90 | * @returns {boolean} 91 | */ 92 | eof () { 93 | return this.peek() === '' 94 | } 95 | 96 | /** 97 | * Throw an error at the current line/column 98 | * 99 | * @public 100 | * @param {string} message 101 | * @throws Error 102 | */ 103 | err (msg) { 104 | throw new Error(`${msg} (${this.line}:${this.column})`) 105 | } 106 | } 107 | 108 | /** 109 | * @function createInputStreamP 110 | * @private 111 | * @param {string} input 112 | * @returns {InputStreamProxy} 113 | */ 114 | module.exports = (input) => { 115 | const i = new InputStream(input) 116 | /** 117 | * @namespace 118 | * @borrows InputStream#position as #position 119 | * @borrows InputStream#peek as #peek 120 | * @borrows InputStream#next as #next 121 | * @borrows InputStream#eof as #eof 122 | * @borrows InputStream#err as #err 123 | */ 124 | const InputStreamProxy = { 125 | position () { 126 | return i.position() 127 | }, 128 | peek () { 129 | return i.peek(...arguments) 130 | }, 131 | next () { 132 | return i.next() 133 | }, 134 | eof () { 135 | return i.eof() 136 | }, 137 | err () { 138 | return i.err(...arguments) 139 | } 140 | } 141 | return InputStreamProxy 142 | } 143 | -------------------------------------------------------------------------------- /lib/parse.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2016, salesforce.com, inc. All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | */ 11 | 12 | /* eslint-disable camelcase */ 13 | 14 | const _ = require('lodash') 15 | const invariant = require('invariant') 16 | 17 | /* 18 | * @typedef {object} Node 19 | * @property {string} type 20 | * @property {string|array} value 21 | * @property {InputStream~Position} start 22 | * @property {InputStream~Position} next 23 | */ 24 | 25 | /** 26 | * Convert a @{link TokenStreamProxy} to a @{link Node} 27 | * 28 | * @protected 29 | * @class 30 | */ 31 | class Parser { 32 | /** 33 | * Create a new InputStream 34 | * 35 | * @param {TokenStreamProxy} tokens 36 | */ 37 | constructor (tokens) { 38 | this.tokens = tokens 39 | } 40 | 41 | /** 42 | * Return a new @{link Node} 43 | * 44 | * @private 45 | * @param {string} type 46 | * @param {string|array} value 47 | * @param {InputStream~Position} start 48 | * @param {InputStream~Position} next 49 | * @returns {Node} 50 | */ 51 | createNode (type, value, start, next) { 52 | return { type, value, start, next } 53 | } 54 | 55 | /** 56 | * Return true if the current token(s) are of the provided type 57 | * and optionally match the specific character(s) 58 | * 59 | * @private 60 | * @param {string} type 61 | * @param {...string} values 62 | * @returns {boolean} 63 | */ 64 | is_type (type, ...values) { 65 | const t = this.tokens.peek() 66 | if (!values.length) return t ? type.test(t.type) : false 67 | return values.reduce((a, c, i) => { 68 | const t = this.tokens.peek(i) 69 | return !t ? false : a && type.test(t.type) && t.value === c 70 | }, true) 71 | } 72 | 73 | /** 74 | * Return true if the current token is a space 75 | * 76 | * @private 77 | * @returns {boolean} 78 | */ 79 | is_space () { 80 | return this.is_type(/space/) 81 | } 82 | 83 | /** 84 | * Return true if the current token is a comment 85 | * 86 | * @private 87 | * @returns {boolean} 88 | */ 89 | is_comment () { 90 | return this.is_type(/comment/) 91 | } 92 | 93 | /** 94 | * Return true if the current token is a punctuation 95 | * 96 | * @private 97 | * @returns {boolean} 98 | */ 99 | is_punctuation () { 100 | return this.is_type(/punctuation/, ...arguments) 101 | } 102 | 103 | /** 104 | * Return true if the current token is an operator 105 | * 106 | * @private 107 | * @returns {boolean} 108 | */ 109 | is_operator () { 110 | return this.is_type(/operator/, ...arguments) 111 | } 112 | 113 | /** 114 | * Return true if the current token is an identifier 115 | * 116 | * @private 117 | * @returns {boolean} 118 | */ 119 | is_identifier () { 120 | return this.is_type(/identifier/, ...arguments) 121 | } 122 | 123 | /** 124 | * Return true if the current token is an atkeyword 125 | * 126 | * @private 127 | * @returns {boolean} 128 | */ 129 | is_atkeyword () { 130 | return this.is_type(/atkeyword/, ...arguments) 131 | } 132 | 133 | /** 134 | * Return true if the current tokens are interpolation 135 | * 136 | * @private 137 | * @returns {boolean} 138 | */ 139 | is_interpolation () { 140 | return this.is_punctuation('#', '{') 141 | } 142 | 143 | /** 144 | * Return the current and next token if the isType predicate succeeds 145 | * 146 | * @private 147 | * @param {string} type 148 | * @param {function} isType 149 | * @param {...string} chars 150 | * @throws Error 151 | * @returns {boolean} 152 | */ 153 | skip_type (type, isType, ...chars) { 154 | if (isType.apply(this, chars)) { 155 | return { start: this.tokens.peek(), next: this.tokens.next() } 156 | } else { 157 | this.tokens.err(`Expecting ${type}: "${chars.join('')}"`) 158 | } 159 | } 160 | 161 | /** 162 | * Expect a punctuation token optionally of the specified type 163 | * 164 | * @private 165 | * @param (...string) chars 166 | * @throws Error 167 | * @returns {boolean} 168 | */ 169 | skip_punctuation () { 170 | return this.skip_type('punctuation', this.is_punctuation, ...arguments) 171 | } 172 | 173 | /** 174 | * Expect an operator token optionally of the specified type 175 | * 176 | * @private 177 | * @param (...string) chars 178 | * @throws Error 179 | * @returns {boolean} 180 | */ 181 | skip_operator () { 182 | return this.skip_type('operator', this.is_operator, ...arguments) 183 | } 184 | 185 | /** 186 | * Expect an atkeyword token 187 | * 188 | * @private 189 | * @throws Error 190 | * @returns {boolean} 191 | */ 192 | skip_atkeyword () { 193 | return this.skip_type('atkeyword', this.is_atkeyword) 194 | } 195 | 196 | /** 197 | * Throw an error at the current token 198 | * 199 | * @private 200 | * @throws Error 201 | */ 202 | unexpected () { 203 | this.tokens.err(`Unexpected token: "${JSON.stringify(this.input.peek())}"`) 204 | } 205 | 206 | /** 207 | * Return a top level stylesheet Node 208 | * 209 | * @public 210 | * @returns {Node} 211 | */ 212 | parse_stylesheet () { 213 | const value = [] 214 | while (!this.tokens.eof()) { 215 | const node = this.parse_node() 216 | if (_.isArray(node)) { 217 | value.push(...node) 218 | } else { 219 | value.push(node) 220 | } 221 | } 222 | return this.createNode('stylesheet', value) 223 | } 224 | 225 | /** 226 | * Parse a top-level Node (atrule,rule,declaration,comment,space) 227 | * 228 | * @private 229 | * @returns {Node|Node[]} 230 | */ 231 | parse_node () { 232 | if ( 233 | this.is_space() || this.is_comment() 234 | ) return this.tokens.next() 235 | 236 | const value = [] 237 | 238 | const maybe_declaration = (punctuation) => { 239 | let expandedPseudo = false 240 | // If the declaration ends with a ";" expand the first pseudo_class 241 | // because pseudo_class can't be part of a declaration property 242 | if (punctuation === ';') { 243 | const pseudoIndex = _.findIndex(value, { 244 | type: 'pseudo_class' 245 | }) 246 | if (pseudoIndex > 0) { 247 | const a = value[pseudoIndex] 248 | const b = this.createNode('punctuation', ':', a.start, _.first(a.value).start) 249 | const nodes = [b].concat(a.value) 250 | value.splice(pseudoIndex, 1, ...nodes) 251 | expandedPseudo = true 252 | } 253 | } 254 | // Try to find a ":" 255 | const puncIndex = _.findIndex(value, { 256 | type: 'punctuation', 257 | value: ':' 258 | }) 259 | // If we found a ":" 260 | if (puncIndex >= 0) { 261 | const maybeSpace = value[puncIndex + 1] 262 | // If we found a space, it wasn't a pseudo class selector, 263 | // so parse it as a declaration 264 | // http://www.sassmeister.com/gist/0e60f53033a44b9e5d99362621143059 265 | if (maybeSpace.type === 'space' || expandedPseudo) { 266 | const start = _.first(value).start 267 | let next = _.last(value).next 268 | const property_ = _.take(value, puncIndex) 269 | const propertyNode = this.createNode( 270 | 'property', property_, _.first(property_).start, _.last(property_).next) 271 | const value_ = _.drop(value, puncIndex + 1) 272 | if (punctuation === '{') { 273 | const block = this.parse_block() 274 | value_.push(block) 275 | next = block.next 276 | } 277 | const valueNode = this.createNode( 278 | 'value', value_, _.first(value_).start, _.last(value_).next) 279 | const declarationValue = [propertyNode, value[puncIndex], valueNode] 280 | if (punctuation === ';') { 281 | const { start } = this.skip_punctuation(';') 282 | declarationValue.push(start) 283 | next = next.start 284 | } 285 | return this.createNode( 286 | 'declaration', declarationValue, start, next) 287 | } 288 | } 289 | return false 290 | } 291 | 292 | while (!this.tokens.eof()) { 293 | // AtRule 294 | if (this.is_atkeyword()) { 295 | return value.concat(this.parse_at_rule()) 296 | } 297 | // Atom 298 | value.push(this.parse_atom()) 299 | // Rule 300 | if (this.is_punctuation('{')) { 301 | if (value.length) { 302 | return maybe_declaration('{') || this.parse_rule(value) 303 | } else { 304 | // TODO: throw error? 305 | return value.concat(this.parse_block()) 306 | } 307 | } 308 | // Declaration 309 | if (this.is_punctuation(';')) { 310 | return maybe_declaration(';') 311 | } 312 | } 313 | return value 314 | } 315 | 316 | /** 317 | * Parse as many atoms as possible while the predicate is true 318 | * 319 | * @private 320 | * @param {function} predicate 321 | * @returns {Node[]} 322 | */ 323 | parse_expression (predicate) { 324 | let value = [] 325 | let declaration = [] 326 | while (true) { 327 | if (this.tokens.eof() || !predicate()) break 328 | // Declaration 329 | if (this.is_punctuation(':') && declaration.length) { 330 | value.push(this.parse_declaration(declaration)) 331 | // Remove the items that are now a declaration 332 | value = _.xor(value, declaration) 333 | declaration = [] 334 | } 335 | // Atom 336 | if (this.tokens.eof() || !predicate()) break 337 | const atom = this.parse_atom() 338 | value.push(atom) 339 | // Collect items that might be parsed as a declaration 340 | // $map: ("red": "blue", "hello": "world"); 341 | switch (atom.type) { 342 | case 'space': 343 | case 'punctuation': 344 | break 345 | default: 346 | declaration.push(atom) 347 | } 348 | } 349 | return value 350 | } 351 | 352 | /** 353 | * Parse a single atom 354 | * 355 | * @private 356 | * @returns {Node} 357 | */ 358 | parse_atom () { 359 | return this.maybe_function(() => { 360 | // Parens 361 | if (this.is_punctuation('(')) { 362 | return this.parse_wrapped('parentheses', '(', ')') 363 | } 364 | // Interpolation 365 | if (this.is_interpolation()) { 366 | return this.parse_interolation() 367 | } 368 | // Attr 369 | if (this.is_punctuation('[')) { 370 | return this.parse_wrapped('attribute', '[', ']') 371 | } 372 | // Class 373 | if (this.is_punctuation('.')) { 374 | return this.parse_selector('class', '.') 375 | } 376 | // Id 377 | if (this.is_punctuation('#')) { 378 | return this.parse_selector('id', '#') 379 | } 380 | // Pseudo Element 381 | if (this.is_punctuation('::')) { 382 | return this.parse_selector('pseudo_element', ':') 383 | } 384 | // Pseudo Class 385 | if (this.is_punctuation(':')) { 386 | const next = this.tokens.peek(1) 387 | if ( 388 | (next.type === 'identifier') || 389 | (next.type === 'punctuation' && next.value === '#') 390 | ) { 391 | return this.parse_selector('pseudo_class', ':') 392 | } 393 | } 394 | // Token 395 | return this.tokens.next() 396 | }) 397 | } 398 | 399 | /** 400 | * Parse a declaration 401 | * 402 | * @private 403 | * @param {Node[]} property 404 | * @returns {Node} 405 | */ 406 | parse_declaration (property) { 407 | const { start: firstSeparator } = this.skip_punctuation(':') 408 | // Expression 409 | let secondSeparator 410 | const value = this.parse_expression(() => { 411 | if (this.is_punctuation(';')) { 412 | secondSeparator = this.tokens.next() 413 | return false 414 | } 415 | if (this.is_punctuation(',')) { 416 | secondSeparator = this.tokens.next() 417 | return false 418 | } 419 | if (this.is_punctuation(')')) return false 420 | return true 421 | }) 422 | const propertyNode = this.createNode( 423 | 'property', property, _.first(property).start, _.last(property).next) 424 | const valueNode = this.createNode( 425 | 'value', value, _.first(value).start, _.last(value).next) 426 | const declarationValue = [propertyNode, firstSeparator, valueNode] 427 | if (secondSeparator) declarationValue.push(secondSeparator) 428 | return this.createNode( 429 | 'declaration', declarationValue, _.first(property).start, _.last(value).next) 430 | } 431 | 432 | /** 433 | * Parse an expression wrapped in the provided chracters 434 | * 435 | * @private 436 | * @param {string} type 437 | * @param {string} open 438 | * @param {string} close 439 | * @param {InputToken~Position} start 440 | * @returns {Node} 441 | */ 442 | parse_wrapped (type, open, close, _start) { 443 | const { start } = this.skip_punctuation(open) 444 | const value = this.parse_expression(() => 445 | !this.is_punctuation(close) 446 | ) 447 | const { next } = this.skip_punctuation(close) 448 | return this.createNode(type, value, (_start || start).start, next.next) 449 | } 450 | 451 | /** 452 | * Parse Nodes wrapped in "{}" 453 | * 454 | * @private 455 | * @returns {Node} 456 | */ 457 | parse_block () { 458 | const { start } = this.skip_punctuation('{') 459 | const value = [] 460 | while ( 461 | (!this.tokens.eof()) && 462 | (!this.is_punctuation('}')) 463 | ) { 464 | const node = this.parse_node() 465 | if (_.isArray(node)) { 466 | value.push(...node) 467 | } else { 468 | value.push(node) 469 | } 470 | } 471 | const { next } = this.skip_punctuation('}') 472 | // Sass allows blocks to end with semicolons 473 | if (this.is_punctuation(';')) { 474 | this.skip_punctuation(';') 475 | } 476 | return this.createNode('block', value, start.start, next.next) 477 | } 478 | 479 | /** 480 | * Parse comma separated expressions wrapped in "()" 481 | * 482 | * @private 483 | * @param {string} [type] the type attrribute of the caller 484 | * @returns {Node} 485 | */ 486 | parse_arguments (type) { 487 | const { start } = this.skip_punctuation('(') 488 | let value = [] 489 | if (type === 'pseudo_class') { 490 | while (!this.tokens.eof() && !this.is_punctuation(')')) { 491 | value.push(this.parse_atom()) 492 | } 493 | } else { 494 | while (!this.tokens.eof() && !this.is_punctuation(')')) { 495 | value = value.concat(this.parse_expression(() => { 496 | if (this.is_punctuation(',')) return false 497 | if (this.is_punctuation(')')) return false 498 | return true 499 | })) 500 | if (this.is_punctuation(',')) { 501 | value.push(this.tokens.next()) 502 | } 503 | } 504 | } 505 | const { next } = this.skip_punctuation(')') 506 | return this.createNode( 507 | 'arguments', value, start.start, next.next) 508 | } 509 | 510 | /** 511 | * Optionally wrap a node in a "function" 512 | * 513 | * @private 514 | * @param {function} node - returns a node to optionally be wrapped 515 | * @returns {Node} 516 | */ 517 | maybe_function (node) { 518 | node = node() 519 | const types = ['identifier', 'function', 'interpolation', 'pseudo_class'] 520 | return this.is_punctuation('(') && _.includes(types, node.type) 521 | ? this.parse_function(node) : node 522 | } 523 | 524 | /** 525 | * Parse a function node 526 | * 527 | * @private 528 | * @params {Node} node - the node to wrap (usually an identifier) 529 | * @returns {Node} 530 | */ 531 | parse_function (node) { 532 | const args = this.parse_arguments(node.type) 533 | return this.createNode( 534 | 'function', [node, args], node.start, args.next) 535 | } 536 | 537 | /** 538 | * Parse interpolation 539 | * 540 | * @private 541 | * @returns {Node} 542 | */ 543 | parse_interolation () { 544 | const { start } = this.skip_punctuation('#') 545 | return this.parse_wrapped('interpolation', '{', '}', start) 546 | } 547 | 548 | /** 549 | * Parse an atrule 550 | * 551 | * @private 552 | * @returns {Node} 553 | */ 554 | parse_at_rule () { 555 | const { start } = this.skip_atkeyword() 556 | const value = [start] 557 | // Space 558 | if (this.is_space()) value.push(this.tokens.next()) 559 | // Identifier (prevent args being converted to a "function") 560 | if (this.is_identifier()) value.push(this.tokens.next()) 561 | // Go 562 | while (!this.tokens.eof()) { 563 | if (this.is_punctuation('(') && /mixin|include|function/.test(start.value)) { 564 | value.push(this.parse_arguments()) 565 | } 566 | if (this.is_punctuation('{')) { 567 | value.push(this.parse_block()) 568 | break 569 | } 570 | if (this.is_punctuation(';')) { 571 | value.push(this.tokens.next()) 572 | break 573 | } else { 574 | value.push(this.parse_atom()) 575 | } 576 | } 577 | return this.createNode('atrule', value, start.start, _.last(value).next) 578 | } 579 | 580 | /** 581 | * Parse a rule 582 | * 583 | * @private 584 | * @param {Node[]} selectors 585 | * @returns {Node} 586 | */ 587 | parse_rule (selectors) { 588 | const selector = this.createNode( 589 | 'selector', selectors, _.first(selectors).start, _.last(selectors).next) 590 | const block = this.parse_block() 591 | return this.createNode( 592 | 'rule', [selector, block], selector.start, block.next) 593 | } 594 | 595 | /** 596 | * Parse selector starting with the provided punctuation 597 | * 598 | * @private 599 | * @param {string} type 600 | * @param {string} punctuation 601 | * @returns {Node} 602 | */ 603 | parse_selector (type, punctuation) { 604 | const { start } = this.skip_punctuation(punctuation) 605 | // Pseudo Element 606 | if (this.is_punctuation(':')) { 607 | this.skip_punctuation(':') 608 | } 609 | const value = [] 610 | let next = this.is_interpolation() 611 | ? this.parse_interolation() : this.tokens.next() 612 | // Selectors can be a combination of identifiers and interpolation 613 | while (next.type === 'identifier' || next.type === 'interpolation' || next.type === 'operator') { 614 | value.push(next) 615 | next = this.is_interpolation() 616 | ? this.parse_interolation() : this.tokens.peek() 617 | if (!next) break 618 | if (next.type === 'identifier') this.tokens.next() 619 | // This is usually a dash following interpolation because identifiers 620 | // can't start with a dash 621 | if (next.type === 'operator') this.tokens.next() 622 | } 623 | if (!value.length) { 624 | this.tokens.err(`Selector ("${type}") expected "identifier" or "interpolation"`) 625 | } 626 | return this.createNode(type, value, start.start, _.last(value).next) 627 | } 628 | } 629 | 630 | /** 631 | * @function parseTokenStream 632 | * @private 633 | * @param {TokenStreamProxt} tokenStream 634 | * @returns {TokenStreamProxy} 635 | */ 636 | module.exports = (tokenStream) => { 637 | invariant( 638 | _.isPlainObject(tokenStream) && _.has(tokenStream, 'next'), 639 | 'Parser requires a TokenStream' 640 | ) 641 | const parser = new Parser(tokenStream) 642 | return parser.parse_stylesheet() 643 | } 644 | -------------------------------------------------------------------------------- /lib/stringify.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2016, salesforce.com, inc. All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | */ 11 | 12 | /* eslint-disable camelcase */ 13 | 14 | const _ = require('lodash') 15 | 16 | const type = { 17 | arguments: (n) => 18 | '(' + walkValue(n.value) + ')', 19 | atkeyword: (n) => 20 | '@' + n.value, 21 | attribute: (n) => 22 | '[' + walkValue(n.value) + ']', 23 | block: (n) => 24 | '{' + walkValue(n.value) + '}', 25 | class: (n) => 26 | '.' + walkValue(n.value), 27 | color_hex: (n) => 28 | '#' + n.value, 29 | id: (n) => 30 | '#' + walkValue(n.value), 31 | interpolation: (n) => 32 | '#{' + walkValue(n.value) + '}', 33 | comment_multiline: (n) => 34 | '/*' + n.value + '*/', 35 | comment_singleline: (n) => 36 | '//' + n.value, 37 | parentheses: (n) => 38 | '(' + walkValue(n.value) + ')', 39 | pseudo_class: (n) => 40 | ':' + walkValue(n.value), 41 | psuedo_element: (n) => 42 | '::' + walkValue(n.value), 43 | string_double: (n) => 44 | `"${n.value}"`, 45 | string_single: (n) => 46 | `'${n.value}'`, 47 | variable: (n) => 48 | '$' + n.value 49 | } 50 | 51 | const walkNode = (node) => { 52 | if (type[node.type]) return type[node.type](node) 53 | if (_.isString(node.value)) return node.value 54 | if (_.isArray(node.value)) return walkValue(node.value) 55 | return '' 56 | } 57 | 58 | const walkValue = (value) => { 59 | if (!_.isArray(value)) return '' 60 | return value.reduce((s, node) => { 61 | return s + walkNode(node) 62 | }, '') 63 | } 64 | 65 | module.exports = (node) => walkNode(node) 66 | -------------------------------------------------------------------------------- /lib/token-stream.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2016, salesforce.com, inc. All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 7 | Neither the name of salesforce.com, inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | */ 11 | 12 | /* eslint-disable camelcase */ 13 | 14 | const _ = require('lodash') 15 | const invariant = require('invariant') 16 | 17 | const HEX_PATTERN = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/ 18 | 19 | /** 20 | * Takes a predicate function and returns its inverse 21 | * 22 | * @private 23 | * @param {function} p 24 | * @returns {function} 25 | */ 26 | const not = (p) => (c) => !p(c) 27 | 28 | /** 29 | * Return a function that matches the provided character 30 | * 31 | * @private 32 | * @param {function} c 33 | * @returns {function} 34 | */ 35 | const is_char = (c) => (cc) => c === cc 36 | 37 | /** 38 | * Return true if the character matches whitespace 39 | * 40 | * @private 41 | * @param {string} c 42 | * @returns {boolean} 43 | */ 44 | const is_whitespace = (c) => '\t\r\n '.indexOf(c) >= 0 45 | 46 | /** 47 | * Return true if the character matches a newline 48 | * 49 | * @private 50 | * @param {string} c 51 | * @returns {boolean} 52 | */ 53 | const is_newline = (c) => c === '\n' 54 | 55 | /** 56 | * Return true if the character matches an operator 57 | * 58 | * @private 59 | * @param {string} c 60 | * @returns {boolean} 61 | */ 62 | const is_operator = (c) => '+-*/%=&|!~><^'.indexOf(c) >= 0 63 | 64 | /** 65 | * Return true if the provided operated can be repeated 66 | * 67 | * @private 68 | * @param {string} c 69 | * @returns {boolean} 70 | */ 71 | const is_operator_repeatable = (c) => '&|='.indexOf(c) >= 0 72 | 73 | /** 74 | * Return true if the character matches a punctuation 75 | * 76 | * @private 77 | * @param {string} c 78 | * @returns {boolean} 79 | */ 80 | const is_punctuation = (c) => ',;(){}[]:#.'.indexOf(c) >= 0 81 | 82 | /** 83 | * Return true if the character matches a digit 84 | * 85 | * @private 86 | * @param {string} c 87 | * @returns {boolean} 88 | */ 89 | const is_digit = (c) => /[0-9]/i.test(c) 90 | 91 | /** 92 | * Return true if input matches a comment 93 | * 94 | * @private 95 | * @param {InputStreamProxt} input 96 | * @returns {boolean} 97 | */ 98 | const is_comment_start = (input) => 99 | (input.peek() === '/' && (input.peek(1) === '/' || input.peek(1) === '*')) 100 | 101 | /** 102 | * Return true if the character matches the start of an identifier 103 | * 104 | * @private 105 | * @param {string} c 106 | * @returns {boolean} 107 | */ 108 | const is_ident_start = (c) => /[a-z_]/i.test(c) 109 | 110 | /** 111 | * Return true if the character matches an identifier 112 | * 113 | * @private 114 | * @param {string} c 115 | * @returns {boolean} 116 | */ 117 | const is_ident = (c) => /[a-z0-9_-]/i.test(c) 118 | 119 | /** 120 | * Return true if input matches the start of a number 121 | * 122 | * @private 123 | * @param {InputStreamProxt} input 124 | * @returns {boolean} 125 | */ 126 | const is_number_start = (input) => 127 | is_digit(input.peek()) || (input.peek() === '.' && is_digit(input.peek(1))) 128 | 129 | /** 130 | * Return the length of a possible hex color 131 | * 132 | * @private 133 | * @param {InputStreamProxt} input 134 | * @returns {number|boolean} 135 | */ 136 | const is_hex = (input) => { 137 | let hex = input.peek() 138 | if (hex === '#') { 139 | let _3 = false 140 | let _6 = false 141 | while (hex.length < 7) { 142 | const c = input.peek(hex.length) 143 | if (_.isEmpty(c)) break 144 | hex += c 145 | if (hex.length === 4) _3 = HEX_PATTERN.test(hex) 146 | if (hex.length === 7) _6 = HEX_PATTERN.test(hex) 147 | } 148 | return _6 ? 6 : _3 ? 3 : false 149 | } 150 | return false 151 | } 152 | 153 | /* 154 | * @typedef {object} Token 155 | * @property {string} type 156 | * @property {string|array} value 157 | * @property {InputStream~Position} start 158 | * @property {InputStream~Position} next 159 | */ 160 | 161 | /** 162 | * Yield tokens from an {@link InputStream} 163 | * 164 | * @protected 165 | * @class 166 | */ 167 | class TokenStream { 168 | /** 169 | * Create a new InputStream 170 | * 171 | * @param {InputStreamProxy} input 172 | */ 173 | constructor (input) { 174 | invariant( 175 | _.isPlainObject(input) && _.has(input, 'next'), 176 | 'TokenStream requires an InputStream' 177 | ) 178 | this.input = input 179 | this.tokens = [] 180 | } 181 | 182 | /** 183 | * Return a new @{link Token} 184 | * 185 | * @private 186 | * @param {string} type 187 | * @param {string|array} value 188 | * @param {InputStream~Position} start 189 | * @returns {Token} 190 | */ 191 | createToken (type, value, start) { 192 | return Object.freeze({ 193 | type, 194 | value, 195 | start, 196 | next: this.input.position() 197 | }) 198 | } 199 | 200 | /** 201 | * Return the current token with an optional offset 202 | * 203 | * @public 204 | * @param {number} offset 205 | * @returns {Token} 206 | */ 207 | peek (offset) { 208 | if (!this.tokens.length) { 209 | const token = this.read_next() 210 | if (token) this.tokens.push(token) 211 | } 212 | if (!offset) return this.tokens[0] 213 | if (offset < this.tokens.length) return this.tokens[offset] 214 | while (this.tokens.length <= offset) { 215 | const token = this.read_next() 216 | if (token) this.tokens.push(token) 217 | else break 218 | } 219 | return this.tokens[offset] 220 | } 221 | 222 | /** 223 | * Return the current token and advance the TokenStream 224 | * 225 | * @public 226 | * @returns {Token} 227 | */ 228 | next () { 229 | const token = this.tokens.shift() 230 | return token || this.read_next() 231 | } 232 | 233 | /** 234 | * Return true if the stream has reached the end 235 | * 236 | * @public 237 | * @returns {boolean} 238 | */ 239 | eof () { 240 | return typeof this.peek() === 'undefined' 241 | } 242 | 243 | /** 244 | * Throw an error at the current line/column 245 | * 246 | * @public 247 | * @param {string} message 248 | * @throws Error 249 | */ 250 | err () { 251 | return this.input.err(...arguments) 252 | } 253 | 254 | /** 255 | * Parse the next character(s) as a Token 256 | * 257 | * @private 258 | * @returns {Token} 259 | */ 260 | read_next () { 261 | if (this.input.eof()) return null 262 | const c = this.input.peek() 263 | // Whitespace 264 | if (is_whitespace(c)) { 265 | return this.read_whitespace() 266 | } 267 | // Comments 268 | if (is_comment_start(this.input)) { 269 | return this.read_comment() 270 | } 271 | // Number 272 | if (is_number_start(this.input)) { 273 | return this.read_number() 274 | } 275 | // Hex 276 | const hex_length = is_hex(this.input) 277 | if (hex_length) { 278 | return this.read_hex(hex_length) 279 | } 280 | // Punctutation 281 | if (is_punctuation(c)) { 282 | return this.read_punctuation() 283 | } 284 | // Identifier 285 | if (is_ident_start(c)) { 286 | return this.read_ident() 287 | } 288 | // Operator 289 | if (is_operator(c)) { 290 | return this.read_operator() 291 | } 292 | // String 293 | if (c === '"' || c === '\'') { 294 | return this.read_string(c) 295 | } 296 | // @ keyword 297 | if (c === '@') { 298 | return this.read_atkeyword() 299 | } 300 | // Variable 301 | if (c === '$') { 302 | return this.read_variable() 303 | } 304 | this.err(`Can't handle character: "${c}"`) 305 | } 306 | 307 | /** 308 | * Advance the input while the prediciate is true 309 | * 310 | * @private 311 | * @param {function} predicate 312 | * @returns {string} 313 | */ 314 | read_while (predicate) { 315 | let s = '' 316 | while (!this.input.eof() && predicate(this.input.peek())) { 317 | s += this.input.next() 318 | } 319 | return s 320 | } 321 | 322 | /** 323 | * Advance the input (consuming escaped characters) until the end character 324 | * is reached 325 | * 326 | * @private 327 | * @param {string} end 328 | * @returns {string} 329 | */ 330 | read_escaped (end) { 331 | let escaped = false 332 | let str = '' 333 | this.input.next() 334 | while (!this.input.eof()) { 335 | const c = this.input.next() 336 | if (escaped) { 337 | str += c 338 | escaped = false 339 | } else if (c === '\\') { 340 | str += c 341 | escaped = true 342 | } else if (c === end) { 343 | break 344 | } else { 345 | str += c 346 | } 347 | } 348 | return str 349 | } 350 | 351 | /** 352 | * Advance the input while whitespace characters are matched 353 | * 354 | * @private 355 | * @returns {Token} 356 | */ 357 | read_whitespace () { 358 | const start = this.input.position() 359 | const value = this.read_while(is_whitespace) 360 | return this.createToken('space', value, start) 361 | } 362 | 363 | /** 364 | * Advance the input while comment characters are matched 365 | * 366 | * @private 367 | * @returns {Token} 368 | */ 369 | read_comment () { 370 | const start = this.input.position() 371 | this.input.next() 372 | switch (this.input.next()) { 373 | case '/': 374 | return this.read_comment_single(start) 375 | case '*': 376 | return this.read_comment_multi(start) 377 | } 378 | } 379 | 380 | /** 381 | * Advance the input while singleline comment characters are matched 382 | * 383 | * @private 384 | * @params {InputStream~Position} start 385 | * @returns {Token} 386 | */ 387 | read_comment_single (start) { 388 | const value = this.read_while(not(is_newline)) 389 | return this.createToken('comment_singleline', value, start) 390 | } 391 | 392 | /** 393 | * Advance the input while multiline comment characters are matched 394 | * 395 | * @private 396 | * @params {InputStream~Position} start 397 | * @returns {Token} 398 | */ 399 | read_comment_multi (start) { 400 | let prev = '' 401 | let value = '' 402 | while (!this.input.eof()) { 403 | const next = this.input.next() 404 | if (next === '/' && prev === '*') break 405 | value += prev 406 | prev = next 407 | } 408 | return this.createToken('comment_multiline', value, start) 409 | } 410 | 411 | /** 412 | * Advance the input while punctuation characters are matched 413 | * 414 | * @private 415 | * @returns {Token} 416 | */ 417 | read_punctuation () { 418 | const start = this.input.position() 419 | const value = this.input.next() 420 | return this.createToken('punctuation', value, start) 421 | } 422 | 423 | /** 424 | * Advance the input while operators characters are matched 425 | * 426 | * @private 427 | * @returns {Token} 428 | */ 429 | read_operator () { 430 | const start = this.input.position() 431 | const c = this.input.peek() 432 | const value = is_operator_repeatable(c) 433 | ? this.read_while(is_char(c)) : this.input.next() 434 | return this.createToken('operator', value, start) 435 | } 436 | 437 | /** 438 | * Advance the input while identifier characters are matched 439 | * 440 | * @private 441 | * @returns {Token} 442 | */ 443 | read_ident () { 444 | const start = this.input.position() 445 | const value = this.read_while(is_ident) 446 | return this.createToken('identifier', value, start) 447 | } 448 | 449 | /** 450 | * Advance the input while string characters are matched 451 | * 452 | * @private 453 | * @param {string} c - " or ' 454 | * @returns {Token} 455 | */ 456 | read_string (c) { 457 | const start = this.input.position() 458 | const value = this.read_escaped(c) 459 | let type = 'string' 460 | if (c === '"') type = 'string_double' 461 | if (c === '\'') type = 'string_single' 462 | return this.createToken(type, value, start) 463 | } 464 | 465 | /** 466 | * Advance the input while number characters are matched 467 | * 468 | * @private 469 | * @returns {Token} 470 | */ 471 | read_number () { 472 | const start = this.input.position() 473 | let hasPoint = false 474 | const value = this.read_while((c) => { 475 | if (c === '.') { 476 | if (hasPoint) return false 477 | hasPoint = true 478 | return true 479 | } 480 | return is_digit(c) 481 | }) 482 | return this.createToken('number', value, start) 483 | } 484 | 485 | /** 486 | * Advance the input while hex characters are matched 487 | * 488 | * @private 489 | * @returns {Token} 490 | */ 491 | read_hex (length) { 492 | const start = this.input.position() 493 | this.input.next() 494 | let value = '' 495 | for (let i = 0; i < length; i++) { 496 | value += this.input.next() 497 | } 498 | return this.createToken('color_hex', value, start) 499 | } 500 | 501 | /** 502 | * Advance the input while atkeyword characters are matched 503 | * 504 | * @private 505 | * @returns {Token} 506 | */ 507 | read_atkeyword () { 508 | const start = this.input.position() 509 | this.input.next() 510 | const value = this.read_while(is_ident) 511 | return this.createToken('atkeyword', value, start) 512 | } 513 | 514 | /** 515 | * Advance the input while variable characters are matched 516 | * 517 | * @private 518 | * @returns {Token} 519 | */ 520 | read_variable () { 521 | const start = this.input.position() 522 | this.input.next() 523 | const value = this.read_while(is_ident) 524 | return this.createToken('variable', value, start) 525 | } 526 | } 527 | 528 | /** 529 | * @function createTokenStream 530 | * @private 531 | * @param {InputStreamProxy} input 532 | * @returns {TokenStreamProxy} 533 | */ 534 | module.exports = (input) => { 535 | const t = new TokenStream(input) 536 | /** 537 | * @namespace 538 | * @borrows TokenStream#peek as #peek 539 | * @borrows TokenStream#next as #next 540 | * @borrows TokenStream#eof as #eof 541 | * @borrows TokenStream#err as #err 542 | */ 543 | const TokenStreamProxy = { 544 | peek () { 545 | return t.peek(...arguments) 546 | }, 547 | next () { 548 | return t.next() 549 | }, 550 | eof () { 551 | return t.eof() 552 | }, 553 | err () { 554 | return t.err(...arguments) 555 | }, 556 | /** 557 | * Yield all tokens from the stream 558 | * 559 | * @instance 560 | * @returns {Token[]} 561 | */ 562 | all () { 563 | const tokens = [] 564 | while (!t.eof()) tokens.push(t.next()) 565 | return tokens 566 | } 567 | } 568 | return TokenStreamProxy 569 | } 570 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scss-parser", 3 | "version": "1.0.6", 4 | "description": "A library to parse/stringify SCSS", 5 | "main": "lib/index.js", 6 | "license": "SEE LICENSE IN README", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/salesforce-ux/scss-parser.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/salesforce-ux/scss-parser/issues" 13 | }, 14 | "engines": { 15 | "node": ">=6.0.0" 16 | }, 17 | "scripts": { 18 | "test": "jest", 19 | "lint": "standard" 20 | }, 21 | "dependencies": { 22 | "invariant": "2.2.4", 23 | "lodash": "4.17.21" 24 | }, 25 | "standard": { 26 | "ignore": [ 27 | "node_modules/**/*" 28 | ] 29 | }, 30 | "devDependencies": { 31 | "jest": "^24.9.0", 32 | "standard": "^14.3.1" 33 | } 34 | } 35 | --------------------------------------------------------------------------------