├── .github └── main.workflow ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── sast-data └── sast-parse ├── index.js ├── package-lock.json ├── package.json ├── src ├── constants.js ├── io.js ├── jsonify.js ├── listify.js ├── mapify.js ├── parse.js ├── split.js ├── stringify.js ├── strip.js └── transform.js └── test ├── fixtures └── basic.scss ├── jsonify.js ├── parse.js └── stringify.js /.github/main.workflow: -------------------------------------------------------------------------------- 1 | workflow "Install, test, publish" { 2 | resolves = ["publish"] 3 | on = "push" 4 | } 5 | 6 | action "install" { 7 | uses = "actions/npm@v2.0.0" 8 | args = "ci" 9 | } 10 | 11 | action "test" { 12 | uses = "actions/npm@v2.0.0" 13 | args = "test" 14 | needs = ["install"] 15 | } 16 | 17 | action "publish" { 18 | uses = "primer/publish@v1.0.0" 19 | needs = ["test"] 20 | secrets = ["GITHUB_TOKEN", "NPM_AUTH_TOKEN"] 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .*.sw? 3 | .DS_Store 4 | node_modules 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sast 2 | This is a thing that parses CSS, Sass, and SCSS into a [unist]-compatible 3 | abstract syntax tree (AST), which makes it possible to then search and 4 | manipulate with all of the wonderful [unist utility modules][utilities]. Most 5 | of the heavy lifting is done by [gonzales]. 6 | 7 | ## Installation 8 | Install it with npm: 9 | 10 | ``` 11 | npm install --save sast 12 | ``` 13 | 14 | ## Usage 15 | You can `import` or `require()` the module and access [the API](#api) as its 16 | methods, like this: 17 | 18 | ```js 19 | // CommonJS, older versions of Node 20 | const sast = require('sast') 21 | 22 | // ES6/ES2016/Babel/etc. 23 | import sast from 'sast' 24 | 25 | const tree = sast.parse('a { color: $red; }', {syntax: 'scss'}) 26 | console.dir(tree, {depth: null}) 27 | ``` 28 | 29 | or you can import just the API methods you need, like so: 30 | 31 | ```js 32 | // CommonJS 33 | const {parse} = require('sast') 34 | // ES6 35 | import {parse} from 'sast' 36 | 37 | const tree = parse('a { color: $red; }', {syntax: 'scss'}) 38 | ``` 39 | 40 | ## API 41 | 42 | ### `sast.parse(source [, options])` 43 | Synchronously parse the CSS, Sass, or SCSS source text (a string) into an 44 | abstract source tree (AST). The default syntax is CSS (`{syntax: 'css'}`); 45 | other acceptable values are `sass`, `scss`, and `less`. See the [gonzales 46 | docs](https://github.com/tonyganch/gonzales-pe#parameters-1) for more info. To 47 | parse files by path, use [`parseFile()`](#parse-file). 48 | 49 | ### `sast.stringify(node)` 50 | Format the resulting AST back into a string, presumably after manipulating it. 51 | 52 | ### `sast.jsonify(node)` 53 | Coerce the given AST node into JSON data, according to the following rules: 54 | 55 | 1. Numbers are numbers: `1` -> `1`, not `"1"`. 56 | 1. Lists become arrays: `(a, 1)` -> `["a", 1]` 57 | 1. [Maps][Sass maps] become objects: `(x: 1)` -> `{x: 1}` 58 | 1. Lists and maps can be nested! 59 | 1. Everything else is [stringified](#stringify), and _should_ be preserved in 60 | its original form: 61 | * Sass/SCSS variables should preserve their leading `$`. 62 | * Hex colors should preserve their leading `#`. 63 | * `rgb()`, `rgba()`, `hsl()`, `hsla()`, and any other functions should 64 | preserve their parentheses. 65 | * Parentheses that are not parsed as lists or maps should be preserved. 66 | 67 | ### `sast.parseFile(filename [, parseOptions={} [, readOptions='utf8'])` 68 | Read a file and parse its contents, returning a Promise. If no 69 | `parseOptions.syntax` is provided, or its value is `auto`, the filename's 70 | extension will be used as the `syntax` option passed to [`parse()`](#parse). 71 | 72 | ```js 73 | const {parseFile} = require('sast') 74 | parseFile('path/to/some.scss') 75 | .then(tree => console.dir(tree, {depth: null})) 76 | .catch(error => console.error('Parse error:', error)) 77 | ``` 78 | 79 | ## CLI 80 | The `sast` [npm package] comes with two command line utilities: 81 | 82 | ### `sast-parse` 83 | Parses a file or stdin as a given syntax, applies one or more simplifying 84 | transformations, then outputs the resulting syntax tree in a variety of 85 | formats: 86 | 87 | * JSON: the raw syntax tree in object form, which can be passed to other CLIs. 88 | * [YAML]: an easier-to-read alternative to JSON, also suitable for piping to 89 | other CLIs. 90 | * Tree: a text representation of the syntax tree provided by 91 | [unist-util-inspect](https://github.com/syntax-tree/unist-util-inspect). 92 | * Text: the [stringified](#stringify) syntax tree, which is hopefully valid for 93 | the given syntax. 94 | 95 | Run `sast-parse --help` for available options. 96 | 97 | ### `sast-data` 98 | Parses one or more SCSS (the only supported syntax at this time) files, and 99 | transforms all top-level variable declarations into key-value pairs. The result 100 | is a JSON object in which each key is a variable name, and the value is the 101 | [jsonified](#jsonify) variable value. 102 | 103 | This is useful for generating [design tokens] from existing SCSS variables if 104 | you don't have the ability to go in the other direction. 105 | 106 | Run `sast-data --help` for available options and more information. 107 | 108 | ## Node types 109 | Most [node types] are defined by [gonzalez], the underlying parser. After 110 | transforming each of the syntax tree nodes into [unist nodes], the following 111 | nodes are introduced: 112 | 113 | ### Maps 114 | Any `parentheses` node whose first `operator` child is a `:` is interpreted as 115 | a [Sass map] and recast as a `map` node. The `children` are preserved as-is, 116 | and key/value pairs separated by `:` and delimited by `,` are placed in the 117 | `values` property as an array of objects with `key` and `value` properties, 118 | each of which is a plain old node list. Some examples: 119 | 120 | * `(x: 1)` will be [jsonified](#jsonify) as `{x: 1}` 121 | * `(x: a, y: 2)` will be interpreted as `{x: "a", y: 2}` 122 | 123 | ### Lists 124 | Any `parentheses` node whose first `operator` child is a `,` is interpreted as 125 | a list (array) and recast as a `list` node. The `children` are perserved as-is, 126 | and children that aren't `space` nodes are split into subgroups by each `,` 127 | operator and converted into `value` nodes with one or more children, then 128 | placed in the `values` property of the `list` node. Some examples: 129 | 130 | * `(1, x)` will be [jsonified](#jsonify) as `[1, "x"]` 131 | * `(a, (b, c))` will be intepreted as `["a", ["b", "c"]]` 132 | 133 | 134 | [gonzales]: https://github.com/tonyganch/gonzales-pe 135 | [node types]: https://github.com/tonyganch/gonzales-pe/blob/dev/docs/node-types.md 136 | [sass map]: https://www.sitepoint.com/using-sass-maps/ 137 | [unist]: https://github.com/syntax-tree/unist 138 | [unist nodes]: https://github.com/syntax-tree/unist#unist-nodes 139 | [utilities]: https://github.com/syntax-tree/unist#list-of-utilities 140 | [npm package]: https://npmjs.com/package/sast 141 | [YAML]: https://en.wikipedia.org/wiki/YAML 142 | -------------------------------------------------------------------------------- /bin/sast-data: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fse = require('fs-extra') 3 | const minimatch = require('minimatch') 4 | const select = require('unist-util-select') 5 | const {parseFile, jsonify, stringify} = require('..') 6 | const YAML = require('js-yaml') 7 | 8 | const yargs = require('yargs') 9 | .usage('$0 [options] []') 10 | .option('syntax', { 11 | desc: `The specific syntax to parse as, or 'auto' to determine from the filename.`, 12 | alias: 's', 13 | }) 14 | .option('format', { 15 | desc: 'Output format', 16 | alias: 'f', 17 | default: 'json', 18 | }) 19 | .option('pretty', { 20 | desc: 'Prettify output', 21 | alias: 'p', 22 | type: 'boolean', 23 | }) 24 | .option('grep', { 25 | desc: 'Only include variable names that match the given glob-like pattern.', 26 | alias: 'g', 27 | type: 'string', 28 | }) 29 | 30 | const formatters = { 31 | 'json': (data, {pretty}) => { 32 | const indent = pretty ? 2 : 0 33 | return JSON.stringify(data, null, ' '.repeat(indent)) 34 | }, 35 | 'yaml': (data, {pretty}) => { 36 | const indent = pretty ? 2 : 0 37 | const opts = {indent} 38 | return YAML.safeDump(data, 'utf8', opts) 39 | }, 40 | } 41 | 42 | const options = yargs.argv 43 | const args = options._ 44 | 45 | const STDIN = '/dev/stdin' 46 | const DASH = '-' 47 | const STDIN_OVERRIDES = { 48 | syntax: options.syntax === 'auto' ? 'scss' : options.syntax 49 | } 50 | 51 | const reads = (args.length ? args : [STDIN]) 52 | .map(file => file === DASH ? STDIN : file) 53 | .map(file => { 54 | const opts = file === STDIN 55 | ? Object.assign({}, options, STDIN_OVERRIDES) 56 | : options 57 | return parseFile(file, opts, 'utf8') 58 | }) 59 | 60 | const getVariables = tree => { 61 | return select(tree, 'stylesheet > declaration') 62 | .reduce((acc, node) => { 63 | const {name, value} = jsonify(node) 64 | if (name) { 65 | acc[name] = value 66 | } 67 | return acc 68 | }, {}) 69 | } 70 | 71 | Promise.all(reads) 72 | .then(files => { 73 | return files.reduce((acc, tree) => { 74 | const vars = getVariables(tree) 75 | console.warn('found %d variable declarations in %s', 76 | Object.keys(vars).length, tree.source) 77 | return Object.assign(acc, vars) 78 | }, {}) 79 | }) 80 | .catch(error => { 81 | console.error(error) 82 | process.exit(1) 83 | }) 84 | .then(vars => { 85 | const {grep, format} = options 86 | if (grep) { 87 | console.warn('grepping: "%s"', grep) 88 | vars = Object.keys(vars) 89 | .filter(key => minimatch(key, grep)) 90 | .reduce((filtered, key) => { 91 | filtered[key] = vars[key] 92 | return filtered 93 | }, {}) 94 | } 95 | const fmt = formatters[format] 96 | const output = fmt(vars, options) 97 | console.log(output) 98 | }) 99 | .catch(error => { 100 | console.error(error) 101 | process.exit(1) 102 | }) 103 | -------------------------------------------------------------------------------- /bin/sast-parse: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const YAML = require('js-yaml') 3 | const fse = require('fs-extra') 4 | const inspect = require('unist-util-inspect') 5 | const is = require('unist-util-is') 6 | const remove = require('unist-util-remove') 7 | const removePosition = require('unist-util-remove-position') 8 | const visit = require('unist-util-visit') 9 | const {jsonify, parse, stringify} = require('..') 10 | 11 | const syntaxes = ['auto', 'css', 'sass', 'scss', 'less'] 12 | 13 | const formatters = { 14 | 'json': (tree, {pretty, indent}) => { 15 | indent = pretty ? 2 : indent 16 | return JSON.stringify(tree, null, ' '.repeat(indent)) 17 | }, 18 | 'string': stringify, 19 | 'tree': (tree, {color}) => color ? inspect(tree) : inspect.noColor(tree), 20 | 'yaml': (tree, {pretty, indent, flowLevel}) => { 21 | const opts = pretty 22 | ? {indent: 2} 23 | : {indent, flowLevel} 24 | return YAML.safeDump(tree, 'utf8', opts) 25 | } 26 | } 27 | 28 | const validateChoice = (name, choices) => { 29 | return choice => { 30 | if (choices.indexOf(choice) === -1) { 31 | throw new Error( 32 | `Invalid ${name}: "${choice}"; expected on of: ${ 33 | choices.map(c => '"' + c + '"').join(', ')}` 34 | ) 35 | } 36 | return choice 37 | } 38 | } 39 | 40 | const yargs = require('yargs') 41 | .usage([ 42 | '$0 [options] []', 43 | '', 44 | 'Parse a CSS, Sass, SCSS, or Less file and output its syntax tree in one of several formats.' 45 | ].join('\n')) 46 | .option('syntax', { 47 | alias: 's', 48 | desc: `Specify the sytax to parse, or infer from the filename (default)`, 49 | choices: syntaxes, 50 | default: 'auto', 51 | type: 'string', 52 | }) 53 | .option('color', { 54 | desc: `Enable colored output for --format=tree`, 55 | type: 'boolean', 56 | alias: 'c', 57 | }) 58 | .option('format', { 59 | desc: `Specify the output format`, 60 | type: 'string', 61 | alias: 'f', 62 | default: 'json', 63 | choices: Object.keys(formatters), 64 | }) 65 | .option('pretty', { 66 | desc: 'Output prettily (for "json" and "yaml" formats only)', 67 | type: 'boolean', 68 | }) 69 | .option('trim', { 70 | desc: 'Trim leading and trailing whitespace from the input', 71 | alias: 't', 72 | type: 'boolean', 73 | }) 74 | .option('no-whitespace', { 75 | desc: 'Discard whitespace in the syntax tree before printing', 76 | alias: 'W', 77 | type: 'boolean', 78 | }) 79 | .option('no-position', { 80 | desc: 'Remove position data from the syntax tree before printing', 81 | alias: 'P', 82 | type: 'boolean', 83 | }) 84 | .strict(true) 85 | 86 | const options = yargs.argv 87 | const args = options._ 88 | delete options._ 89 | // console.warn('[x] options:', options) 90 | 91 | const input = args.shift() || '/dev/stdin' 92 | const format = formatters[options.format] 93 | 94 | fse.readFile(input, 'utf8') 95 | .then(source => { 96 | if (options.trim) { 97 | // console.warn('[x] trimming the input') 98 | source = source.trim() 99 | } 100 | if (options.syntax === 'auto') { 101 | options.syntax = input.split('.').pop() 102 | } 103 | return parse(source, options) 104 | }) 105 | .catch(error => { 106 | console.error(error) 107 | process.exit(1) 108 | }) 109 | .then(tree => { 110 | if (options.noPosition) { 111 | // console.warn('[x] removing position data') 112 | removePosition(tree, true) 113 | } 114 | if (options.noWhitespace) { 115 | // console.warn('[x] removing whitespace') 116 | remove(tree, 'space') 117 | } 118 | const output = format(tree, options) 119 | console.log(output) 120 | }) 121 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const {parse, unistify} = require('./src/parse') 2 | const {parseFile} = require('./src/io') 3 | const jsonify = require('./src/jsonify') 4 | const stringify = require('./src/stringify') 5 | 6 | module.exports = { 7 | parse, 8 | parseFile, 9 | stringify, 10 | jsonify, 11 | unistify, 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sast", 3 | "version": "0.8.1", 4 | "description": "Parse CSS, Sass, and SCSS into Unist syntax trees", 5 | "main": "index.js", 6 | "bin": { 7 | "sast-parse": "bin/sast-parse", 8 | "sast-data": "bin/sast-data" 9 | }, 10 | "scripts": { 11 | "test": "ava --verbose test/*.js" 12 | }, 13 | "keywords": [ 14 | "css", 15 | "sass", 16 | "sast", 17 | "scss", 18 | "syntax-tree", 19 | "unist" 20 | ], 21 | "repository": "github:shawnbot/sast", 22 | "author": "Shawn Allen ", 23 | "license": "Unlicense", 24 | "dependencies": { 25 | "fs-extra": "^4.0.2", 26 | "gonzales-pe": "^4.2.2", 27 | "invariant": "^2.2.2", 28 | "js-yaml": "^3.13.1", 29 | "minimatch": "^3.0.4", 30 | "unist-util-find": "^1.0.1", 31 | "unist-util-inspect": "^4.1.1", 32 | "unist-util-is": "^2.1.1", 33 | "unist-util-map": "^1.0.3", 34 | "unist-util-remove": "^1.0.0", 35 | "unist-util-remove-position": "^1.1.1", 36 | "unist-util-select": "^1.5.0", 37 | "unist-util-visit": "^1.1.3", 38 | "unist-util-visit-parents": "^1.1.1", 39 | "yargs": "^9.0.1" 40 | }, 41 | "devDependencies": { 42 | "ava": "^1.4.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | const OPERATOR = 'operator' 2 | const COLON = {type: OPERATOR, value: ':'} 3 | const COMMA = {type: OPERATOR, value: ','} 4 | const LIST = 'list' 5 | const MAP = 'map' 6 | const PARENS = 'parentheses' 7 | const IDENT = 'ident' 8 | const SPACE = 'space' 9 | 10 | module.exports = { 11 | COLON, 12 | COMMA, 13 | IDENT, 14 | LIST, 15 | MAP, 16 | PARENS, 17 | SPACE, 18 | } 19 | -------------------------------------------------------------------------------- /src/io.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra') 2 | const {parse} = require('./parse') 3 | 4 | const SYNTAX_AUTO = 'auto' 5 | 6 | const parseFile = (filename, parseOptions={}, readOptions='utf8') => { 7 | let {syntax} = parseOptions 8 | if (!syntax || syntax === SYNTAX_AUTO) { 9 | if (filename.includes('.')) { 10 | parseOptions.syntax = filename.split('.').pop() 11 | // console.warn('detected auto syntax:', syntax) 12 | } else { 13 | throw new Error( 14 | `"auto" syntax requires a filename with an extension; got "${filename}"` 15 | ) 16 | } 17 | } 18 | return fse.readFile(filename, readOptions) 19 | .then(content => parse(content, parseOptions)) 20 | .then(tree => { 21 | // the path is immutable, so define it with a getter 22 | Object.defineProperty(tree.source, 'path', { 23 | get: () => filename 24 | }) 25 | return tree 26 | }) 27 | } 28 | 29 | module.exports = { 30 | parseFile, 31 | } 32 | -------------------------------------------------------------------------------- /src/jsonify.js: -------------------------------------------------------------------------------- 1 | const is = require('unist-util-is') 2 | const map = require('unist-util-map') 3 | const strip = require('./strip') 4 | const stringify = require('./stringify') 5 | const select = require('unist-util-select') 6 | 7 | const extraneousValueStuff = node => is(['default', 'space'], node) 8 | 9 | const transforms = { 10 | 'declaration': node => { 11 | // be lazy and just find the first ident and value nodes 12 | const ident = select(node, 'property ident')[0] 13 | const value = select(node, 'value')[0] 14 | return { 15 | name: stringify(ident), 16 | value: jsonify(value), 17 | } 18 | }, 19 | 'list': node => { 20 | return node.values.map(jsonify) 21 | }, 22 | 'map': node => { 23 | return node.values.reduce((acc, {name, value}) => { 24 | acc[stringify(name)] = jsonify(value) 25 | return acc 26 | }, {}) 27 | }, 28 | 'number': node => Number(node.value), 29 | 'value': node => { 30 | // strip the !default and intervening space 31 | strip(extraneousValueStuff, node.children) 32 | return node.children.length > 1 33 | ? stringify(node) 34 | : jsonify(node.children[0]) 35 | }, 36 | } 37 | 38 | const jsonify = node => { 39 | if (Array.isArray(node)) { 40 | throw new Error('Expected node object in jsonify(), but got Array') 41 | } 42 | const transform = transforms[node.type] 43 | if (typeof transform === 'function') { 44 | // console.warn('transforming:', node.type) 45 | return transform(node) 46 | } 47 | return stringify(node) 48 | } 49 | 50 | module.exports = jsonify 51 | -------------------------------------------------------------------------------- /src/listify.js: -------------------------------------------------------------------------------- 1 | const invariant = require('invariant') 2 | const split = require('./split') 3 | const stringify = require('./stringify') 4 | const strip = require('./strip') 5 | const {COLON, COMMA, LIST, PARENS, SPACE} = require('./constants') 6 | 7 | module.exports = node => { 8 | invariant(node.type === PARENS, `Expected a ${PARENS} node, but got "${node.type}"`) 9 | const values = split(node.children, COMMA) 10 | .map(nodes => { 11 | return { 12 | type: 'value', 13 | children: strip(SPACE, nodes) 14 | } 15 | }) 16 | return Object.assign(node, { 17 | type: LIST, 18 | values, 19 | }) 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/mapify.js: -------------------------------------------------------------------------------- 1 | const invariant = require('invariant') 2 | const is = require('unist-util-is') 3 | const split = require('./split') 4 | const strip = require('./strip') 5 | const stringify = require('./stringify') 6 | const {COLON, COMMA, MAP, PARENS, SPACE} = require('./constants') 7 | 8 | module.exports = node => { 9 | invariant(node.type === PARENS, `Expected a ${PARENS} node, but got "${node.type}"`) 10 | return Object.assign(node, { 11 | type: MAP, 12 | values: split(node.children, COMMA) 13 | .map(part => { 14 | const [[name], [value]] = split(part, COLON) 15 | .map(d => strip(SPACE, d)) 16 | return { 17 | type: 'name-value', 18 | name, 19 | value: value, 20 | children: part 21 | } 22 | }), 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/parse.js: -------------------------------------------------------------------------------- 1 | const gonzales = require('gonzales-pe') 2 | const map = require('unist-util-map') 3 | const transform = require('./transform') 4 | 5 | const PARSE_DEFAULTS = { 6 | syntax: 'scss', 7 | } 8 | 9 | const parse = (source, options) => { 10 | const gtree = gonzales.parse( 11 | source, 12 | Object.assign({}, PARSE_DEFAULTS, options) 13 | ) 14 | const tree = degonzify(gtree) 15 | tree.source = {} 16 | Object.defineProperty(tree.source, 'string', { 17 | enumerable: false, 18 | get: () => source 19 | }) 20 | return unistify(tree) 21 | } 22 | 23 | const degonzify = src => { 24 | const {type, start, end, content} = src 25 | const node = { 26 | type, 27 | position: { 28 | start, 29 | end 30 | } 31 | } 32 | if (Array.isArray(content)) { 33 | node.children = content.map(degonzify) 34 | } else { 35 | node.value = content 36 | } 37 | return node 38 | } 39 | 40 | const unistify = tree => { 41 | return map(tree, transform) 42 | } 43 | 44 | module.exports = { 45 | parse, 46 | unistify 47 | } 48 | -------------------------------------------------------------------------------- /src/split.js: -------------------------------------------------------------------------------- 1 | const is = require('unist-util-is') 2 | 3 | module.exports = (children, separator) => { 4 | const slices = [] 5 | let slice = [] 6 | children.forEach(child => { 7 | if (is(separator, child)) { 8 | slices.push(slice) 9 | slice = [] 10 | } else { 11 | slice.push(child) 12 | } 13 | }) 14 | if (slice.length > 0) { 15 | slices.push(slice) 16 | } 17 | return slices 18 | } 19 | -------------------------------------------------------------------------------- /src/stringify.js: -------------------------------------------------------------------------------- 1 | const invariant = require('invariant') 2 | 3 | const stringify = (node, depth=0) => { 4 | invariant(node, 'You must provide a node to stringify()') 5 | // const indent = ' '.repeat(depth) 6 | // console.warn(indent, 'stringify', node.type) 7 | 8 | if (Array.isArray(node)) { 9 | throw new Error('Expected node object in stringify(), but got Array') 10 | } 11 | 12 | let buffer 13 | if (node.children) { 14 | buffer = node.children 15 | .reduce((buff, child) => buff.concat(stringify(child, depth + 1)), []) 16 | } else { 17 | buffer = [node.value] 18 | } 19 | 20 | const mod = mods[node.type] 21 | if (typeof mod === 'function') { 22 | buffer = mod(buffer, node) 23 | } else { 24 | // console.warn(indent, `no buffer mod for "${node.type}"`) 25 | } 26 | return buffer.join('') 27 | } 28 | 29 | // higher order buffer manipulation functions 30 | const wrap = (before, after) => b => [before, ...b, after] 31 | const prefix = str => b => [str, ...b] 32 | const suffix = str => b => [...b, str] 33 | const parens = wrap('(', ')') 34 | const curlies = wrap('{', '}') 35 | 36 | const mods = { 37 | 'arguments': parens, 38 | 'atkeyword': prefix('@'), 39 | 'attributeSelector': wrap('[', ']'), 40 | 'block': curlies, 41 | // NOTE: only hex values are 'color' nodes in gonzales-pe land 42 | 'color': prefix('#'), 43 | 'class': prefix('.'), 44 | 'id': prefix('#'), 45 | 'list': parens, 46 | 'map': parens, 47 | 'multilineComment': wrap('/*', '*/'), 48 | 'parentheses': parens, 49 | 'percentage': suffix('%'), 50 | 'pseudoClass': prefix(':'), 51 | 'pseudoElement': prefix('::'), 52 | 'singlelineComment': prefix('//'), 53 | 'uri': wrap('url(', ')'), 54 | // universal selector nodes have no content in gonzales-pe 55 | 'universalSelector': () => ['*'], 56 | 'variable': prefix('$'), 57 | } 58 | 59 | module.exports = stringify 60 | -------------------------------------------------------------------------------- /src/strip.js: -------------------------------------------------------------------------------- 1 | const is = require('unist-util-is') 2 | 3 | module.exports = (test, nodes) => { 4 | while (nodes.length && is(test, nodes[0])) { 5 | nodes.shift() 6 | } 7 | while (nodes.length && is(test, nodes[nodes.length - 1])) { 8 | nodes.pop() 9 | } 10 | return nodes 11 | } 12 | -------------------------------------------------------------------------------- /src/transform.js: -------------------------------------------------------------------------------- 1 | const invariant = require('invariant') 2 | const is = require('unist-util-is') 3 | const listify = require('./listify') 4 | const mapify = require('./mapify') 5 | const {COLON, COMMA, IDENT} = require('./constants') 6 | 7 | const isOperator = node => is([COLON, COMMA], node) 8 | 9 | const parentheses = node => { 10 | const [op] = node.children.filter(isOperator) 11 | if (op) { 12 | switch (op.value) { 13 | case COLON.value: return mapify(node) 14 | case COMMA.value: return listify(node) 15 | } 16 | } 17 | return node 18 | } 19 | 20 | const property = node => { 21 | const [ident] = node.children.filter(child => is(IDENT, child)) 22 | node.name = ident ? ident.value : null 23 | return node 24 | } 25 | 26 | const transforms = { 27 | parentheses, 28 | property 29 | } 30 | 31 | module.exports = node => { 32 | const {type} = node 33 | const transform = transforms[type] 34 | if (typeof transform === 'function') { 35 | node = transform(node) 36 | return node 37 | } 38 | return node 39 | } 40 | -------------------------------------------------------------------------------- /test/fixtures/basic.scss: -------------------------------------------------------------------------------- 1 | $red: #ff0000; 2 | -------------------------------------------------------------------------------- /test/jsonify.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const test = require('ava') 3 | const visit = require('unist-util-visit') 4 | const {parse, jsonify} = require('..') 5 | 6 | const fixture = (...args) => path.resolve(__dirname, 'fixtures', ...args) 7 | 8 | test('it preserves anything with a value', t => { 9 | t.is(jsonify({value: 'foo'}), 'foo') 10 | t.is(jsonify({type: 'unknown', value: 'foo'}), 'foo') 11 | }) 12 | 13 | test('it transforms lists to arrays', t => { 14 | visit(parse('$foo: (a, 1)'), 'list', list => { 15 | t.deepEqual(jsonify(list), ['a', 1]) 16 | t.pass() 17 | }) 18 | }) 19 | 20 | test('it transforms map to objects', t => { 21 | visit(parse('$foo: (a: 1, b: 2)'), 'map', map => { 22 | t.deepEqual(jsonify(map), {a: 1, b: 2}) 23 | t.pass() 24 | }) 25 | }) 26 | 27 | test('it transforms lists in maps', t => { 28 | visit(parse('$foo: (a: (1, 2))'), 'map', map => { 29 | t.deepEqual(jsonify(map), {a: [1, 2]}) 30 | t.pass() 31 | }) 32 | }) 33 | 34 | test('it transforms maps in lists', t => { 35 | visit(parse('$foo: ((a: 1), (b: 2))'), 'list', list => { 36 | t.deepEqual(jsonify(list), [{a: 1}, {b: 2}]) 37 | t.pass() 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/parse.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra') 2 | const path = require('path') 3 | const remove = require('unist-util-remove') 4 | const select = require('unist-util-select') 5 | const test = require('ava') 6 | const {parse, parseFile, stringify} = require('..') 7 | 8 | const fixture = (...args) => path.resolve(__dirname, 'fixtures', ...args) 9 | 10 | test('it parses SCSS by default', t => { 11 | const root = parse('$foo: bar') 12 | t.deepEqual(root.type, 'stylesheet') 13 | t.deepEqual(root.children.length, 1) 14 | t.deepEqual(root.children[0].type, 'declaration') 15 | }) 16 | 17 | test('it parses lists', t => { 18 | const root = parse('$list: (bar, baz)') 19 | t.is(root.children.length, 1) 20 | const [decl] = root.children 21 | t.is(decl.children.length, 4) 22 | const [name, colon, space, value] = decl.children 23 | const list = value.children[0] 24 | t.is(list.type, 'list') 25 | t.is(list.values.length, 2) 26 | t.deepEqual(list.values.map(stringify), ['bar', 'baz']) 27 | }) 28 | 29 | test('it parses maps', t => { 30 | const root = parse('$list: (a: b, c: d)') 31 | t.is(root.children.length, 1) 32 | const [decl] = root.children 33 | t.is(decl.children.length, 4) 34 | const [name, colon, space, value] = decl.children 35 | const map = value.children[0] 36 | t.is(map.type, 'map') 37 | t.is(map.values.length, 2) 38 | t.deepEqual( 39 | map.values.map(({name, value}) => ({ 40 | name: stringify(name), 41 | value: stringify(value), 42 | })), 43 | [ 44 | {name: 'a', value: 'b'}, 45 | {name: 'c', value: 'd'}, 46 | ] 47 | ) 48 | }) 49 | 50 | test('it parses files', t => { 51 | return parseFile(fixture('basic.scss')).then(tree => { 52 | t.is(tree.type, 'stylesheet') 53 | }) 54 | }) 55 | 56 | test('it remembers the source string', t => { 57 | const file = fixture('basic.scss') 58 | return fse.readFile(file, 'utf8').then(source => { 59 | return parseFile(file).then(tree => { 60 | t.is(tree.source.string, source) 61 | t.falsy(Object.keys(tree.source).includes('string'), 62 | 'tree.source.string is enumerable') 63 | }) 64 | }) 65 | }) 66 | 67 | test('it remembers the source path', t => { 68 | const file = fixture('basic.scss') 69 | return parseFile(file).then(tree => { 70 | t.is(tree.source.path, file) 71 | t.falsy(Object.keys(tree.source).includes('path'), 72 | 'tree.source.path is enumerable') 73 | }) 74 | }) 75 | 76 | test('it creates a tree that works with unist-util-select', t => { 77 | const tree = parse(` 78 | $color: red; 79 | a { 80 | color: $color; 81 | } 82 | `) 83 | 84 | remove(tree, 'space') 85 | t.is(select(tree, 'stylesheet')[0], tree) 86 | const firstVariable = tree.children[0].children[0].children[0] 87 | t.is(select(tree, 'declaration variable')[0], firstVariable) 88 | }) 89 | 90 | test('it adds names to property nodes', t => { 91 | const tree = parse('a { color: green; }', {syntax: 'css'}) 92 | const [property] = select(tree, 'property') 93 | t.is(property.name, 'color', 'property lacks name "color"') 94 | const selected = select(tree, 'property[name=color]') 95 | t.deepEqual(selected, [property]) 96 | }) 97 | -------------------------------------------------------------------------------- /test/stringify.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const test = require('ava') 3 | const select = require('unist-util-select') 4 | const {parse, stringify} = require('..') 5 | 6 | const fixture = (...args) => path.resolve(__dirname, 'fixtures', ...args) 7 | 8 | const preserves = (message, strings) => { 9 | test(`it preserves ${message}`, t => { 10 | strings.forEach(str => { 11 | const parsed = parse(str) 12 | t.is(stringify(parsed), str, 13 | 'types: ' + select(parsed, '*').map(n => n.type).join(', ')) 14 | }) 15 | }) 16 | } 17 | 18 | test('it preserves anything with a value', t => { 19 | t.is(stringify({value: 'foo'}), 'foo') 20 | t.is(stringify({type: 'unknown', value: 'foo'}), 'foo') 21 | }) 22 | 23 | preserves('variables', [ 24 | '$foo: 1', 25 | '$foo-bar: 1', 26 | ]) 27 | 28 | preserves('unit values', [ 29 | 'x { height: 100%; }', 30 | 'x { width: 100px; }', 31 | 'x { width: 10em; }', 32 | 'x { width: 10rem; }', 33 | ]) 34 | 35 | preserves('blocks', [ 36 | 'x { color: red; }', 37 | 'x { color: red; y { color: blue; } }', 38 | ]) 39 | 40 | preserves('@include', [ 41 | '@include breakpoint(md)', 42 | '@include breakpoint()', 43 | '@include breakpoint', 44 | ]) 45 | 46 | preserves('@mixin', [ 47 | '@mixin breakpoint($size) { }', 48 | '@mixin breakpoint { }', 49 | ]) 50 | 51 | preserves('all manner of parentheses', [ 52 | '$x: (1, 2)', 53 | '$x: (1, (2, 2, (1 + 2)))', 54 | '$x: (1, (2, 2, ($y + 2 * 5)))', 55 | ]) 56 | 57 | preserves('functions', [ 58 | '$x: y(1)', 59 | '$x: y(z(1 + 2))', 60 | '$x: $y( z(1 + 2) )', 61 | '$x: $z( 1 + 2 )', 62 | ]) 63 | 64 | preserves('color values', [ 65 | '$color: #f00', 66 | '$color: red', 67 | '$color: rgb(255, 0, 0)', 68 | '$color: rgba(255, 0, 0, 0.5)', 69 | '$color: rgba(#f00, 0.5)', 70 | ]) 71 | 72 | preserves('universal selectors', [ 73 | '* { box-sizing: border-box; }', 74 | '* > li { list-style: none; }', 75 | 'dl > * { list-style: none; }', 76 | ]) 77 | 78 | preserves('class selectors', [ 79 | '.foo { }', 80 | '.foo.bar { }', 81 | 'x.foo { }', 82 | 'x.foo.bar { }', 83 | 'x.foo .bar { }', 84 | ]) 85 | 86 | // see: 87 | preserves('attribute selectors', [ 88 | '[data-foo] { }', 89 | 'x[data-foo] { }', 90 | 'x[data-foo=x] { }', 91 | 'x[data-foo="x"] { }', 92 | 'x[data-foo^="x"] { }', 93 | 'x[data-foo*="x"] { }', 94 | 'x[data-foo$="x"] { }', 95 | 'x[data-foo|="x"] { }', 96 | // deep cuts here 97 | 'x[data-foo=x i] { }', 98 | 'x[data-foo="x" i] { }', 99 | 'x[data-foo~="x" i] { }', 100 | ]) 101 | 102 | preserves('placeholders', [ 103 | 'a:hover { &:active { color: green; } }', 104 | 'a.foo { &:hover { color: red; } }', 105 | ]) 106 | 107 | preserves('pseudo-classes', [ 108 | 'a:hover { color: red; }', 109 | 'a:hover { &:active { color: green; } }', 110 | 'a:-vendor-thing { color: red; }', 111 | ]) 112 | 113 | preserves('pseudo-elements', [ 114 | 'a::before { content: "hi"; }', 115 | 'a::-vendor-thing { display: none; }', 116 | // browsers understand this 117 | 'a:before { content: "hi"; }', 118 | ]) 119 | 120 | preserves('comma-separated values', [ 121 | 'x { background: red, green, blue; }', 122 | ]) 123 | 124 | preserves('URLs', [ 125 | 'a { background-url: url(foo.png); }', 126 | 'a { background-url: url("foo.png"); }', 127 | "a { background-url: url('foo.png'); }", 128 | ]) 129 | --------------------------------------------------------------------------------