├── .eslintignore ├── .eslintrc ├── .github ├── funding.yml ├── CONTRIBUTORS.md ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .editorconfig ├── test ├── parser │ ├── declaration.test.js │ ├── function.test.js │ ├── params.test.js │ ├── escaping.test.js │ ├── postcss-sanity.test.js │ ├── variables.test.js │ ├── import.test.js │ ├── interpolation.test.js │ ├── extend.test.js │ ├── comments.test.js │ └── mixins.test.js ├── postcss.test.js ├── map.test.js ├── stringify.test.js ├── integration.test.js └── integration │ └── ext.cx.dashboard.less ├── lib ├── nodes │ ├── mixin.js │ ├── import.js │ ├── interpolation.js │ ├── variable.js │ └── inline-comment.js ├── LessStringifier.js ├── index.js └── LessParser.js ├── LICENSE ├── package.json ├── .circleci └── config.yml └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | _lib 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "shellscape" 3 | } 4 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | patreon: shellscape 2 | custom: https://paypal.me/shellscape 3 | liberapay: shellscape 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .eslintcache 3 | .nyc_output 4 | .vscode 5 | coverage.lcov 6 | npm-debug.log* 7 | Thumbs.db 8 | node_modules 9 | 10 | s.js 11 | -------------------------------------------------------------------------------- /.github/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | * Jeff Lindsey 2 | * Denys Kniazevych 3 | * Pat Sissons 4 | * Andrey Sitnik 5 | * Andrew Powell -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .editorconfig 3 | .eslintignore 4 | .eslintrc 5 | .gitignore 6 | .idea 7 | .npmignore 8 | .travis.yml 9 | 10 | gulpfile.babel.js 11 | 12 | .vscode 13 | build/ 14 | node_modules/ 15 | test/ 16 | 17 | npm-debug.log 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml}] 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing in @rollup-cabal 2 | 3 | We 💛 contributions! The rules for contributing to this project are few: 4 | 5 | 1. Don't be a jerk 6 | 1. Ask nicely 7 | 1. Search issues before opening a new one 8 | 1. Lint and run tests locally before submitting a PR 9 | 1. Adhere to the code style the project has chosen 10 | -------------------------------------------------------------------------------- /test/parser/declaration.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const { parse, nodeToString } = require('../../lib'); 4 | 5 | test('should allow case-insensitive !important (#89)', (t) => { 6 | const less = 'a{k: v !IMPORTANT;}'; 7 | const root = parse(less); 8 | const node = root.first.first; 9 | 10 | t.is(node.value, 'v'); 11 | t.true(node.important); 12 | t.is(nodeToString(root), less); 13 | }); 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Which issue #** if any, does this resolve? 2 | 3 | 4 | 5 | Please check one: 6 | - [ ] New tests created for this change 7 | - [ ] Tests updated for this change 8 | 9 | This PR: 10 | - [ ] Adds new API 11 | - [ ] Extends existing API, backwards-compatible 12 | - [ ] Introduces a breaking change 13 | - [ ] Fixes a bug 14 | 15 | --- 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/parser/function.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const { parse, nodeToString } = require('../../lib'); 4 | 5 | test('each (#121)', (t) => { 6 | const params = `(@colors, { 7 | .@{value}-color { 8 | color: @value; 9 | } 10 | })`; 11 | const less = `each${params};`; 12 | const root = parse(less); 13 | const { first } = root; 14 | 15 | t.is(first.name, 'each'); 16 | t.is(first.params, params); 17 | t.truthy(first.function); 18 | 19 | t.is(nodeToString(root), less); 20 | }); 21 | -------------------------------------------------------------------------------- /lib/nodes/mixin.js: -------------------------------------------------------------------------------- 1 | const hashColorPattern = /^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{3}$/; 2 | const unpaddedFractionalNumbersPattern = /\.[0-9]/; 3 | 4 | const isMixinToken = (token) => { 5 | const [, symbol] = token; 6 | const [char] = symbol; 7 | 8 | return ( 9 | (char === '.' || char === '#') && 10 | // ignore hashes used for colors 11 | hashColorPattern.test(symbol) === false && 12 | // ignore dots used for unpadded fractional numbers 13 | unpaddedFractionalNumbersPattern.test(symbol) === false 14 | ); 15 | }; 16 | 17 | module.exports = { isMixinToken }; 18 | -------------------------------------------------------------------------------- /lib/nodes/import.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies, no-param-reassign */ 2 | 3 | const tokenize = require('postcss/lib/tokenize'); 4 | 5 | const urlPattern = /^url\((.+)\)/; 6 | 7 | module.exports = (node) => { 8 | const { name, params = '' } = node; 9 | 10 | if (name === 'import' && params.length) { 11 | node.import = true; 12 | 13 | const tokenizer = tokenize({ css: params }); 14 | 15 | node.filename = params.replace(urlPattern, '$1'); 16 | 17 | while (!tokenizer.endOfFile()) { 18 | const [type, content] = tokenizer.nextToken(); 19 | 20 | if (type === 'word' && content === 'url') { 21 | return; 22 | } else if (type === 'brackets') { 23 | node.options = content; 24 | node.filename = params.replace(content, '').trim(); 25 | break; 26 | } 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /lib/nodes/interpolation.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: off */ 2 | 3 | module.exports = { 4 | interpolation(token) { 5 | const tokens = [token, this.tokenizer.nextToken()]; 6 | const validTypes = ['word', '}']; 7 | 8 | // look for @{ but not @[word]{ 9 | if (tokens[0][1].length > 1 || tokens[1][0] !== '{') { 10 | this.tokenizer.back(tokens[1]); 11 | return false; 12 | } 13 | 14 | token = this.tokenizer.nextToken(); 15 | while (token && validTypes.includes(token[0])) { 16 | tokens.push(token); 17 | token = this.tokenizer.nextToken(); 18 | } 19 | 20 | const words = tokens.map((tokn) => tokn[1]); 21 | const [first] = tokens; 22 | const last = tokens.pop(); 23 | const newToken = ['word', words.join(''), first[2], last[2]]; 24 | 25 | this.tokenizer.back(token); 26 | this.tokenizer.back(newToken); 27 | 28 | return true; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | * Node Version: 8 | * NPM Version: 9 | * postcss Version: 10 | * postcss-less Version: 11 | 12 | If you have a large amount of code to share which demonstrates the problem you're experiencing, please provide a link to your 13 | repository rather than pasting code. Otherwise, please paste relevant short snippets below. 14 | 15 | ### LESS 16 | 17 | ```less 18 | // less that demonstrates the issue 19 | ``` 20 | 21 | ### JavaScript 22 | 23 | ```js 24 | // js that reproduces the issue 25 | ``` 26 | 27 | ### Errors 28 | 29 | ``` 30 | // actual error output, if error was thrown 31 | ``` 32 | 33 | ### Expected Behavior 34 | 35 | ### Actual Behavior 36 | 37 | ### How can we reproduce the behavior? 38 | -------------------------------------------------------------------------------- /lib/nodes/variable.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: off */ 2 | 3 | const afterPattern = /:$/; 4 | const beforePattern = /^:(\s+)?/; 5 | // const bracketsPattern = /\{/; 6 | 7 | module.exports = (node) => { 8 | const { name, params = '' } = node; 9 | 10 | // situations like @page :last { color: red } should default to the built-in AtRule 11 | // LESS variables are @name : value; < note that for them to be valid LESS vars, they must end in 12 | // a semicolon. 13 | 14 | if (node.name.slice(-1) !== ':') { 15 | return; 16 | } 17 | 18 | if (afterPattern.test(name)) { 19 | const [match] = name.match(afterPattern); 20 | 21 | node.name = name.replace(match, ''); 22 | node.raws.afterName = match + (node.raws.afterName || ''); 23 | node.variable = true; 24 | node.value = node.params; 25 | } 26 | 27 | if (beforePattern.test(params)) { 28 | const [match] = params.match(beforePattern); 29 | 30 | node.value = params.replace(match, ''); 31 | node.raws.afterName = (node.raws.afterName || '') + match; 32 | node.variable = true; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /test/parser/params.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const { parse, nodeToString } = require('../../lib'); 4 | 5 | test('should not assign parameters for pseudo-selectors (#56)', (t) => { 6 | const less = '.test2:not(.test3) {}'; 7 | const root = parse(less); 8 | const { first } = root; 9 | 10 | t.is(first.selector, '.test2:not(.test3)'); 11 | t.falsy(first.params); 12 | t.is(nodeToString(root), less); 13 | }); 14 | 15 | // sanity check = require(issue #99 16 | test('should not assign parameters for bracket selectors', (t) => { 17 | const less = 18 | '@media only screen and ( max-width: ( @narrow - 1px ) ) {\n padding: 10px 24px 20px;\n}'; 19 | const root = parse(less); 20 | const { first } = root; 21 | 22 | t.is(first.type, 'atrule'); 23 | t.is(nodeToString(root), less); 24 | }); 25 | 26 | test('should not assign parameters for bracket selectors (2)', (t) => { 27 | const less = '.test1,.test2[test=test] {}'; 28 | const root = parse(less); 29 | const { first } = root; 30 | 31 | t.is(first.selector, '.test1,.test2[test=test]'); 32 | t.falsy(first.params); 33 | t.is(nodeToString(root), less); 34 | }); 35 | -------------------------------------------------------------------------------- /test/postcss.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const postcss = require('postcss'); 3 | const CssSyntaxError = require('postcss/lib/css-syntax-error'); 4 | 5 | const syntax = require('../lib'); 6 | 7 | const { parser } = syntax; 8 | 9 | // silence the rediculously verbose "You did not set any plugins, parser, or 10 | // stringifier" warnings in PostCSS. 11 | console.warn = () => {}; // eslint-disable-line no-console 12 | 13 | test('should process LESS syntax', async (t) => { 14 | const less = 'a { b {} }'; 15 | const result = await postcss().process(less, { syntax, parser }); 16 | 17 | t.truthy(result); 18 | t.is(result.css, less); 19 | t.is(result.content, less); 20 | }); 21 | 22 | test('should not parse invalid LESS (#64)', async (t) => { 23 | const less = '.@{]'; 24 | 25 | try { 26 | await postcss().process(less, { syntax }); 27 | } catch (e) { 28 | t.true(e instanceof CssSyntaxError); 29 | } 30 | }); 31 | 32 | test('should create its own Root node stringifier (#82)', async (t) => { 33 | const less = '@const "foo.less"'; 34 | const result = await postcss().process(less, { syntax }); 35 | 36 | t.is(result.root.toString(), less); 37 | }); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Andrey Sitnik 4 | Copyright (c) 2016 Denys Kniazevych 5 | Copyright (c) 2016 Pat Sissons 6 | Copyright (c) 2017 Andrew Powell 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /test/parser/escaping.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const { parse, nodeToString } = require('../../lib'); 4 | 5 | test('parses escaped string', (t) => { 6 | const less = ` 7 | @testVar: 10px; 8 | 9 | .test-wrap { 10 | .selector { 11 | height: calc(~"100vh - @{testVar}"); 12 | } 13 | } 14 | `; 15 | const root = parse(less); 16 | const { first, last } = root; 17 | 18 | t.is(first.name, 'testVar'); 19 | t.is(first.value, '10px'); 20 | t.is(last.first.first.prop, 'height'); 21 | t.is(last.first.first.value, 'calc(~"100vh - @{testVar}")'); 22 | t.is(nodeToString(root), less); 23 | }); 24 | 25 | test('parses escaping inside nested rules', (t) => { 26 | const less = ` 27 | .test1 { 28 | .another-test { 29 | prop1: function(~"@{variable}"); 30 | } 31 | } 32 | 33 | .test2 { 34 | prop2: function(~\`@{test}\`); 35 | } 36 | 37 | .test3 { 38 | filter: ~"alpha(opacity='@{opacity}')"; 39 | } 40 | `; 41 | const root = parse(less); 42 | 43 | t.is(root.nodes[0].first.first.prop, 'prop1'); 44 | t.is(root.nodes[0].first.first.value, 'function(~"@{variable}")'); 45 | t.is(root.nodes[1].first.prop, 'prop2'); 46 | t.is(root.nodes[1].first.value, 'function(~`@{test}`)'); 47 | t.is(root.nodes[2].first.prop, 'filter'); 48 | t.is(root.nodes[2].first.value, '~"alpha(opacity=\'@{opacity}\')"'); 49 | t.is(nodeToString(root), less); 50 | }); 51 | -------------------------------------------------------------------------------- /test/map.test.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs'); 2 | 3 | const { join } = require('path'); 4 | 5 | const test = require('ava'); 6 | const postcss = require('postcss'); 7 | 8 | const syntax = require('../lib'); 9 | 10 | const { parser } = syntax; 11 | 12 | // silence the rediculously verbose "You did not set any plugins, parser, or 13 | // stringifier" warnings in PostCSS. 14 | console.warn = () => {}; // eslint-disable-line no-console 15 | 16 | test('should parse LESS integration syntax and generate a source map', async (t) => { 17 | const less = readFileSync(join(__dirname, './integration/ext.cx.dashboard.less'), 'utf-8'); 18 | const result = await postcss().process(less, { 19 | syntax, 20 | parser, 21 | map: { inline: false, annotation: false, sourcesContent: true } 22 | }); 23 | 24 | t.truthy(result); 25 | t.is(result.css, less); 26 | t.is(result.content, less); 27 | t.truthy(result.map); 28 | }); 29 | 30 | test('should parse LESS inline comment syntax and generate a source map', async (t) => { 31 | const less = ` 32 | a { 33 | //background-color: red; 34 | } 35 | `; 36 | const result = await postcss().process(less, { 37 | syntax, 38 | parser, 39 | map: { inline: false, annotation: false, sourcesContent: true } 40 | }); 41 | 42 | t.truthy(result); 43 | t.is(result.css, less); 44 | t.is(result.content, less); 45 | t.truthy(result.map); 46 | }); 47 | -------------------------------------------------------------------------------- /lib/LessStringifier.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | const Stringifier = require('postcss/lib/stringifier'); 4 | 5 | module.exports = class LessStringifier extends Stringifier { 6 | atrule(node, semicolon) { 7 | if (!node.mixin && !node.variable && !node.function) { 8 | super.atrule(node, semicolon); 9 | return; 10 | } 11 | 12 | const identifier = node.function ? '' : node.raws.identifier || '@'; 13 | let name = `${identifier}${node.name}`; 14 | let params = node.params ? this.rawValue(node, 'params') : ''; 15 | const important = node.raws.important || ''; 16 | 17 | if (node.variable) { 18 | params = node.value; 19 | } 20 | 21 | if (typeof node.raws.afterName !== 'undefined') { 22 | name += node.raws.afterName; 23 | } else if (params) { 24 | name += ' '; 25 | } 26 | 27 | if (node.nodes) { 28 | this.block(node, name + params + important); 29 | } else { 30 | const end = (node.raws.between || '') + important + (semicolon ? ';' : ''); 31 | this.builder(name + params + end, node); 32 | } 33 | } 34 | 35 | comment(node) { 36 | if (node.inline) { 37 | const left = this.raw(node, 'left', 'commentLeft'); 38 | const right = this.raw(node, 'right', 'commentRight'); 39 | this.builder(`//${left}${node.text}${right}`, node); 40 | } else { 41 | super.comment(node); 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /test/stringify.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const syntax = require('../lib'); 4 | 5 | const { parse, nodeToString } = syntax; 6 | 7 | const run = (less) => { 8 | const root = parse(less); 9 | return nodeToString(root); 10 | }; 11 | 12 | test('inline comment', (t) => { 13 | const result = run('// comment\na {}'); 14 | t.is(result, '// comment\na {}'); 15 | }); 16 | 17 | test('inline comment in the end of file', (t) => { 18 | const result = run('// comment'); 19 | t.is(result, '// comment'); 20 | }); 21 | 22 | test('mixin without body', async (t) => { 23 | const less = '.selector:extend(.f, .g) {&:extend(.a);}'; 24 | const result = run(less); 25 | t.is(result, less); 26 | }); 27 | 28 | test('mixins', (t) => { 29 | const less = '.foo (@bar; @baz...) { border: baz; }'; 30 | const result = run(less); 31 | t.is(result, less); 32 | }); 33 | 34 | test('mixin without body. #2', async (t) => { 35 | const less = '.mix() {color: red} .selector {.mix()}'; 36 | const result = run(less); 37 | t.is(result, less); 38 | }); 39 | 40 | test('mixin without body. #3', async (t) => { 41 | const less = ` 42 | .container { 43 | .mixin-1(); 44 | .mixin-2; 45 | .mixin-3 (@width: 100px) { 46 | width: @width; 47 | } 48 | } 49 | 50 | .rotation(@deg:5deg){ 51 | .transform(rotate(@deg)); 52 | } 53 | `; 54 | 55 | const result = run(less); 56 | t.is(result, less); 57 | }); 58 | 59 | test('mixin with !important', async (t) => { 60 | const less = '.foo { .mix() ! important; }'; 61 | const result = run(less); 62 | t.is(result, less); 63 | }); 64 | -------------------------------------------------------------------------------- /test/integration.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-await-in-loop: off */ 2 | 3 | // TODO: enable when interpolation has been fixed 4 | // const { readFileSync } = require('fs'); 5 | // const { join } = require('path'); 6 | 7 | const test = require('ava'); 8 | const cheerio = require('cheerio'); 9 | const isAbsoluteUrl = require('is-absolute-url'); 10 | const fetch = require('node-fetch'); 11 | const postcss = require('postcss'); 12 | const urljoin = require('url-join'); 13 | 14 | const syntax = require('../lib'); 15 | 16 | const sites = ['https://github.com', 'https://news.ycombinator.com']; 17 | 18 | for (const site of sites) { 19 | test(`integration: ${site}`, async (t) => { 20 | const res = await fetch(site); 21 | const html = await res.text(); 22 | const $ = cheerio.load(html); 23 | 24 | const hrefs = $('head link[rel=stylesheet]') 25 | .filter((index, sheet) => !!sheet.attribs.href) 26 | .map((index, sheet) => { 27 | let { href } = sheet.attribs; 28 | 29 | if (!isAbsoluteUrl(href)) { 30 | href = urljoin(site, href); 31 | } 32 | 33 | return href; 34 | }) 35 | .get(); 36 | 37 | for (const href of hrefs) { 38 | const req = await fetch(href); 39 | const css = await req.text(); 40 | 41 | await postcss().process(css, { 42 | parser: syntax.parse, 43 | map: { annotation: false }, 44 | from: null 45 | }); 46 | } 47 | 48 | t.pass(); 49 | }); 50 | } 51 | 52 | // sanity check - issue #99 53 | // TODO: enable when interpolation has been fixed 54 | // test('should not fail wikimedia sanity check', (t) => { 55 | // const less = readFileSync(join(__dirname, './integration/ext.cx.dashboard.less'), 'utf-8'); 56 | // const root = syntax.parse(less); 57 | // 58 | // t.is(root.first.type, 'import'); 59 | // }); 60 | -------------------------------------------------------------------------------- /test/parser/postcss-sanity.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const { eachTest, jsonify } = require('postcss-parser-tests'); 3 | 4 | const { nodeToString, parse, stringify } = require('../../lib'); 5 | 6 | eachTest((name, code, json) => { 7 | // Skip comments.css, because we have an extended Comment node 8 | if (name === 'comments.css' || name === 'atrule-no-space.css' || name === 'inside.css') { 9 | return; 10 | } 11 | 12 | test(`parses ${name}`, (t) => { 13 | const root = parse(code, { from: name }); 14 | const parsed = jsonify(root); 15 | 16 | t.is(parsed, json); 17 | }); 18 | 19 | if (name === 'bom.css') { 20 | return; // eslint-disable-line no-useless-return 21 | } 22 | 23 | test(`stringifies ${name}`, (t) => { 24 | const root = parse(code); 25 | let result = ''; 26 | 27 | stringify(root, (i) => { 28 | result += i; 29 | }); 30 | 31 | t.is(result, code); 32 | }); 33 | }); 34 | 35 | test('parses nested rules', (t) => { 36 | const root = parse('a { b {} }'); 37 | 38 | t.is(root.first.first.selector, 'b'); 39 | }); 40 | 41 | test('parses at-rules inside rules', (t) => { 42 | const root = parse('a { @media {} }'); 43 | 44 | t.is(root.first.first.name, 'media'); 45 | }); 46 | 47 | test('nested media query with import (#103)', (t) => { 48 | const less = `@media screen { 49 | @import "basic"; 50 | }`; 51 | const root = parse(less); 52 | const { first } = root; 53 | 54 | t.is(first.first.name, 'import'); 55 | t.is(nodeToString(root), less); 56 | }); 57 | 58 | test('detached ruleset (#86)', (t) => { 59 | const params = `({ 60 | .hello { 61 | .test { 62 | } 63 | } 64 | 65 | .fred { 66 | } 67 | })`; 68 | const less = `.test${params}`; 69 | const root = parse(less); 70 | const { first } = root; 71 | 72 | t.true(first.mixin); 73 | t.is(first.name, 'test'); 74 | t.is(first.params, params); 75 | t.is(nodeToString(root), less); 76 | }); 77 | -------------------------------------------------------------------------------- /test/parser/variables.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const AtRule = require('postcss/lib/at-rule'); 3 | 4 | const { parse, nodeToString } = require('../../lib'); 5 | 6 | test('numeric', (t) => { 7 | const less = '@var: 1;'; 8 | const root = parse(less); 9 | const { first } = root; 10 | 11 | t.true(first instanceof AtRule); 12 | t.is(first.name, 'var'); 13 | t.is(first.value, '1'); 14 | t.is(nodeToString(root), less); 15 | }); 16 | 17 | test('@pagexxx like variable but not @page selector', (t) => { 18 | let less = '@pageWidth: "test";'; 19 | let root = parse(less); 20 | let { first } = root; 21 | 22 | t.is(first.name, 'pageWidth'); 23 | t.is(first.value, '"test"'); 24 | t.is(nodeToString(root), less); 25 | 26 | less = '@page-width: "test";'; 27 | root = parse(less); 28 | ({ first } = root); 29 | 30 | t.is(first.name, 'page-width'); 31 | t.is(first.value, '"test"'); 32 | t.is(nodeToString(root), less); 33 | }); 34 | 35 | test('string values', (t) => { 36 | const less = '@var: "test";'; 37 | const root = parse(less); 38 | const { first } = root; 39 | 40 | t.is(first.name, 'var'); 41 | t.is(first.value, '"test"'); 42 | t.is(nodeToString(root), less); 43 | }); 44 | 45 | test('mixed variables', (t) => { 46 | const propValue = '( \n( ((@line-height))) * (@lines-to-show) )em'; 47 | const less = `h1 { max-height: ${propValue}; }`; 48 | const root = parse(less); 49 | const { first } = root; 50 | 51 | t.is(first.selector, 'h1'); 52 | t.is(first.first.prop, 'max-height'); 53 | t.is(first.first.value, propValue); 54 | t.is(nodeToString(root), less); 55 | }); 56 | 57 | test('color (hash) variables', (t) => { 58 | const less = '@var: #fff;'; 59 | const root = parse(less); 60 | const { first } = root; 61 | 62 | t.is(first.name, 'var'); 63 | t.is(first.value, '#fff'); 64 | t.is(nodeToString(root), less); 65 | }); 66 | 67 | test('multiline position (#108)', (t) => { 68 | const less = `@var: ' 69 | foo 70 | bar 71 | '; 72 | `; 73 | const root = parse(less); 74 | const { first } = root; 75 | 76 | t.is(first.name, 'var'); 77 | t.is(first.value, `'\nfoo\nbar\n'`); 78 | t.is(first.source.end.line, 4); 79 | t.is(nodeToString(root), less); 80 | }); 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-less", 3 | "version": "6.0.0", 4 | "description": "LESS parser for PostCSS", 5 | "license": "MIT", 6 | "repository": "shellscape/postcss-less", 7 | "author": "Denys Kniazevych ", 8 | "maintainer": "Andrew Powell ", 9 | "homepage": "https://github.com/shellscape/postcss-less", 10 | "bugs": "https://github.com/shellscape/postcss-less/issues", 11 | "main": "lib/index.js", 12 | "engines": { 13 | "node": ">=12" 14 | }, 15 | "scripts": { 16 | "ci:coverage": "nyc npm run test && nyc report --reporter=text-lcov > coverage.lcov", 17 | "ci:lint": "npm run lint && npm run security", 18 | "ci:test": "npm run test", 19 | "commitlint": "commitlint", 20 | "commitmsg": "commitlint -e $GIT_PARAMS", 21 | "lint": "eslint --fix --cache lib test", 22 | "lint-staged": "lint-staged", 23 | "security": "npm audit --audit-level=high", 24 | "test": "ava" 25 | }, 26 | "files": [ 27 | "lib", 28 | "README.md", 29 | "LICENSE" 30 | ], 31 | "dependencies": {}, 32 | "devDependencies": { 33 | "@commitlint/cli": "^12.1.4", 34 | "@commitlint/config-conventional": "^12.1.4", 35 | "ava": "^3.15.0", 36 | "cheerio": "^1.0.0-rc.10", 37 | "eslint-config-shellscape": "^2.0.2", 38 | "eslint-plugin-filenames": "^1.2.0", 39 | "is-absolute-url": "^3.0.0", 40 | "less": "^4.1.1", 41 | "lint-staged": "^11.0.0", 42 | "node-fetch": "^2.6.1", 43 | "nyc": "^15.1.0", 44 | "postcss": "^8.3.5", 45 | "postcss-parser-tests": "^8.3.5", 46 | "pre-commit": "^1.2.2", 47 | "url-join": "^4.0.0" 48 | }, 49 | "peerDependencies": { 50 | "postcss": "^8.3.5" 51 | }, 52 | "keywords": [ 53 | "css", 54 | "postcss", 55 | "postcss-syntax", 56 | "parser", 57 | "less" 58 | ], 59 | "ava": { 60 | "files": [ 61 | "!**/fixtures/**", 62 | "!**/helpers/**" 63 | ] 64 | }, 65 | "lint-staged": { 66 | "*.js": [ 67 | "eslint --fix" 68 | ] 69 | }, 70 | "nyc": { 71 | "include": [ 72 | "lib/*.js" 73 | ], 74 | "exclude": [ 75 | "lib/client*.js", 76 | "test/" 77 | ] 78 | }, 79 | "pre-commit": "lint-staged" 80 | } 81 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | unit_tests: &unit_tests 2 | steps: 3 | - checkout 4 | - restore_cache: 5 | key: dependency-cache-{{ checksum "package-lock.json" }} 6 | - run: 7 | name: NPM Rebuild 8 | command: npm install 9 | - run: 10 | name: Run unit tests. 11 | command: npm run ci:test 12 | jobs: 13 | dependency_cache: 14 | docker: 15 | - image: rollupcabal/circleci-node-base:latest 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | key: dependency-cache-{{ checksum "package-lock.json" }} 20 | - run: 21 | name: Install Dependencies 22 | command: npm install 23 | - save_cache: 24 | key: dependency-cache-{{ checksum "package-lock.json" }} 25 | paths: 26 | - ./node_modules 27 | node-v12-latest: 28 | docker: 29 | - image: rollupcabal/circleci-node-v12:latest 30 | steps: 31 | - checkout 32 | - restore_cache: 33 | key: dependency-cache-{{ checksum "package-lock.json" }} 34 | - run: 35 | name: NPM Rebuild 36 | command: npm install 37 | - run: 38 | name: Run unit tests. 39 | command: npm run ci:coverage 40 | - run: 41 | name: Submit coverage data to codecov. 42 | command: bash <(curl -s https://codecov.io/bash) 43 | when: on_success 44 | node-v14-latest: 45 | docker: 46 | - image: rollupcabal/circleci-node-v14:latest 47 | <<: *unit_tests 48 | analysis: 49 | docker: 50 | - image: rollupcabal/circleci-node-base:latest 51 | steps: 52 | - checkout 53 | - restore_cache: 54 | key: dependency-cache-{{ checksum "package-lock.json" }} 55 | - run: 56 | name: NPM Rebuild 57 | command: npm install 58 | - run: 59 | name: Run Linting 60 | command: npm run lint 61 | - run: 62 | name: Run NPM Audit 63 | command: npm run security 64 | version: 2.0 65 | workflows: 66 | version: 2 67 | validate: 68 | jobs: 69 | - dependency_cache 70 | - analysis: 71 | requires: 72 | - dependency_cache 73 | filters: 74 | tags: 75 | only: /.*/ 76 | - node-v14-latest: 77 | requires: 78 | - analysis 79 | filters: 80 | tags: 81 | only: /.*/ 82 | - node-v12-latest: 83 | requires: 84 | - analysis 85 | filters: 86 | tags: 87 | only: /.*/ 88 | -------------------------------------------------------------------------------- /lib/nodes/inline-comment.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies, no-param-reassign */ 2 | 3 | const tokenizer = require('postcss/lib/tokenize'); 4 | const Input = require('postcss/lib/input'); 5 | 6 | module.exports = { 7 | isInlineComment(token) { 8 | if (token[0] === 'word' && token[1].slice(0, 2) === '//') { 9 | const first = token; 10 | const bits = []; 11 | let endOffset; 12 | let remainingInput; 13 | 14 | while (token) { 15 | if (/\r?\n/.test(token[1])) { 16 | // If there are quotes, fix tokenizer creating one token from start quote to end quote 17 | if (/['"].*\r?\n/.test(token[1])) { 18 | // Add string before newline to inline comment token 19 | bits.push(token[1].substring(0, token[1].indexOf('\n'))); 20 | 21 | // Get remaining input and retokenize 22 | remainingInput = token[1].substring(token[1].indexOf('\n')); 23 | const untokenizedRemainingInput = this.input.css 24 | .valueOf() 25 | .substring(this.tokenizer.position()); 26 | remainingInput += untokenizedRemainingInput; 27 | 28 | endOffset = token[3] + untokenizedRemainingInput.length - remainingInput.length; 29 | } else { 30 | // If the tokenizer went to the next line go back 31 | this.tokenizer.back(token); 32 | } 33 | break; 34 | } 35 | 36 | bits.push(token[1]); 37 | // eslint-disable-next-line prefer-destructuring 38 | endOffset = token[2]; 39 | token = this.tokenizer.nextToken({ ignoreUnclosed: true }); 40 | } 41 | 42 | const newToken = ['comment', bits.join(''), first[2], endOffset]; 43 | this.inlineComment(newToken); 44 | 45 | // Replace tokenizer to retokenize the rest of the string 46 | // we need replace it after we added new token with inline comment because token position is calculated for old input (#145) 47 | if (remainingInput) { 48 | this.input = new Input(remainingInput); 49 | this.tokenizer = tokenizer(this.input); 50 | } 51 | 52 | return true; 53 | } else if (token[1] === '/') { 54 | // issue #135 55 | const next = this.tokenizer.nextToken({ ignoreUnclosed: true }); 56 | 57 | if (next[0] === 'comment' && /^\/\*/.test(next[1])) { 58 | next[0] = 'word'; 59 | next[1] = next[1].slice(1); 60 | token[1] = '//'; 61 | this.tokenizer.back(next); 62 | return module.exports.isInlineComment.bind(this)(token); 63 | } 64 | } 65 | 66 | return false; 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /test/parser/import.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const AtRule = require('postcss/lib/at-rule'); 3 | 4 | const { parse, nodeToString } = require('../../lib'); 5 | 6 | test('@at-rules != @imports', async (t) => { 7 | const less = '@const "foo.less";'; 8 | const root = parse(less); 9 | const { 10 | first, 11 | source: { input } 12 | } = root; 13 | 14 | t.truthy(root); 15 | t.is(input.css, less); 16 | t.true(first instanceof AtRule); 17 | t.falsy(first.import); 18 | t.is(nodeToString(root), less); 19 | }); 20 | 21 | test('@imports', async (t) => { 22 | const less = '@import "foo.less";'; 23 | const root = parse(less); 24 | const { 25 | first, 26 | source: { input } 27 | } = root; 28 | 29 | t.truthy(root); 30 | t.is(input.css, less); 31 | t.true(first.import); 32 | t.is(first.filename, '"foo.less"'); 33 | t.is(nodeToString(root), less); 34 | }); 35 | 36 | test('import options', async (t) => { 37 | const less = '@import (inline) "foo.less";'; 38 | const root = parse(less); 39 | 40 | t.is(root.first.options, '(inline)'); 41 | t.is(nodeToString(root), less); 42 | }); 43 | 44 | test('multiple import options', async (t) => { 45 | const less = '@import (inline, once, optional) "foo.less";'; 46 | const root = parse(less); 47 | 48 | t.is(root.first.options, '(inline, once, optional)'); 49 | t.is(nodeToString(root), less); 50 | }); 51 | 52 | test('url("filename")', async (t) => { 53 | const less = '@import url("foo.less");'; 54 | const root = parse(less); 55 | 56 | t.is(root.first.filename, '"foo.less"'); 57 | t.is(nodeToString(root), less); 58 | }); 59 | 60 | test('url(filename)', async (t) => { 61 | const less = '@import url(foo.less);'; 62 | const root = parse(less); 63 | 64 | t.is(root.first.filename, 'foo.less'); 65 | t.is(nodeToString(root), less); 66 | }); 67 | 68 | test('no spaces', async (t) => { 69 | const less = '@import"foo.less";'; 70 | const root = parse(less); 71 | 72 | t.is(root.first.filename, '"foo.less"'); 73 | t.is(nodeToString(root), less); 74 | }); 75 | 76 | test('malformed filename (#88)', async (t) => { 77 | const less = '@import missing "missing" "not missing";'; 78 | const root = parse(less); 79 | 80 | t.is(root.first.filename, 'missing "missing" "not missing"'); 81 | t.is(nodeToString(root), less); 82 | }); 83 | 84 | test('mmissing semicolon (#88)', async (t) => { 85 | const less = ` 86 | @import "../assets/font/league-gothic/league-gothic.css" 87 | 88 | .ManagerPage { 89 | height: 100%; 90 | }`; 91 | const root = parse(less); 92 | 93 | t.is(root.first.filename, '"../assets/font/league-gothic/league-gothic.css"\n\n.ManagerPage'); 94 | t.is(nodeToString(root), less); 95 | }); 96 | -------------------------------------------------------------------------------- /test/parser/interpolation.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-useless-escape: off */ 2 | 3 | const test = require('ava'); 4 | 5 | const { parse } = require('../../lib'); 6 | 7 | test('parses interpolation', (t) => { 8 | const root = parse('@{selector}:hover { @{prop}-size: @{color} }'); 9 | 10 | t.is(root.first.selector, '@{selector}:hover'); 11 | t.is(root.first.first.prop, '@{prop}-size'); 12 | t.is(root.first.first.value, '@{color}'); 13 | }); 14 | 15 | test('parses interpolation when there is not space between selector with open bracket', (t) => { 16 | const root = parse('@{selector}-title{ @{prop}-size: @{color} }'); 17 | 18 | t.is(root.first.selector, '@{selector}-title'); 19 | t.is(root.first.first.prop, '@{prop}-size'); 20 | t.is(root.first.first.value, '@{color}'); 21 | }); 22 | 23 | test('parses mixin interpolation', (t) => { 24 | const less = '.browser-prefix(@prop, @args) {\n @{prop}: @args;\n}'; 25 | const root = parse(less); 26 | 27 | t.is(root.first.selector, '.browser-prefix(@prop, @args)'); 28 | t.is(root.first.first.prop, '@{prop}'); 29 | t.is(root.first.first.value, '@args'); 30 | }); 31 | 32 | test('parses interpolation inside word', (t) => { 33 | const root = parse('.@{class} {}'); 34 | 35 | t.is(root.first.selector, '.@{class}'); 36 | }); 37 | 38 | test('parses non-interpolation', (t) => { 39 | const root = parse('\\@{ color: black }'); 40 | 41 | t.is(root.first.selector, '\\@'); 42 | }); 43 | 44 | // TODO: interpolation doesn't quite work yet 45 | test('interpolation', (t) => { 46 | const root = parse('@{selector}:hover { @{prop}-size: @{color} }'); 47 | const { first } = root; 48 | 49 | t.is(first.selector, '@{selector}:hover'); 50 | t.is(first.first.prop, '@{prop}-size'); 51 | t.is(first.first.value, '@{color}'); 52 | }); 53 | 54 | test('interpolation inside word', (t) => { 55 | const root = parse('.@{class} {}'); 56 | const { first } = root; 57 | t.is(first.selector, '.@{class}'); 58 | }); 59 | 60 | test('parses escaping', (t) => { 61 | const code = ` 62 | .m_transition (...) { 63 | @props: ~\`"@{arguments}".replace(/[\[\]]/g, '')\`; 64 | @var: ~ a; 65 | -webkit-transition: @props; 66 | -moz-transition: @props; 67 | -o-transition: @props; 68 | transition: @props; 69 | } 70 | 71 | .a { 72 | & ~ .stock-bar__content .stock-bar__control_pause { 73 | display: none; 74 | } 75 | } 76 | `; 77 | 78 | const root = parse(code); 79 | const { first } = root; 80 | t.is(first.selector, '.m_transition (...)'); 81 | t.is(first.first.name, 'props'); 82 | t.is(first.first.value, '~`"@{arguments}".replace(/[[]]/g, \'\')`'); 83 | t.is(root.nodes[1].first.selector, '& ~ .stock-bar__content .stock-bar__control_pause'); 84 | }); 85 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | const Input = require('postcss/lib/input'); 4 | 5 | const LessParser = require('./LessParser'); 6 | const LessStringifier = require('./LessStringifier'); 7 | 8 | module.exports = { 9 | parse(less, options) { 10 | const input = new Input(less, options); 11 | const parser = new LessParser(input); 12 | 13 | parser.parse(); 14 | 15 | // To handle double-slash comments (`//`) we end up creating a new tokenizer 16 | // in certain cases (see `lib/nodes/inline-comment.js`). However, this means 17 | // that any following node in the AST will have incorrect start/end positions 18 | // on the `source` property. To fix that, we'll walk the AST and compute 19 | // updated positions for all nodes. 20 | parser.root.walk((node) => { 21 | const offset = input.css.lastIndexOf(node.source.input.css); 22 | 23 | if (offset === 0) { 24 | // Short circuit - this node was processed with the original tokenizer 25 | // and should therefore have correct position information. 26 | return; 27 | } 28 | 29 | // This ensures that the chunk of source we're processing corresponds 30 | // strictly to a terminal substring of the input CSS. This should always 31 | // be the case, but if it ever isn't, we prefer to fail instead of 32 | // producing potentially invalid output. 33 | // istanbul ignore next 34 | if (offset + node.source.input.css.length !== input.css.length) { 35 | throw new Error('Invalid state detected in postcss-less'); 36 | } 37 | 38 | const newStartOffset = offset + node.source.start.offset; 39 | const newStartPosition = input.fromOffset(offset + node.source.start.offset); 40 | 41 | // eslint-disable-next-line no-param-reassign 42 | node.source.start = { 43 | offset: newStartOffset, 44 | line: newStartPosition.line, 45 | column: newStartPosition.col 46 | }; 47 | 48 | // Not all nodes have an `end` property. 49 | if (node.source.end) { 50 | const newEndOffset = offset + node.source.end.offset; 51 | const newEndPosition = input.fromOffset(offset + node.source.end.offset); 52 | 53 | // eslint-disable-next-line no-param-reassign 54 | node.source.end = { 55 | offset: newEndOffset, 56 | line: newEndPosition.line, 57 | column: newEndPosition.col 58 | }; 59 | } 60 | }); 61 | 62 | return parser.root; 63 | }, 64 | 65 | stringify(node, builder) { 66 | const stringifier = new LessStringifier(builder); 67 | stringifier.stringify(node); 68 | }, 69 | 70 | nodeToString(node) { 71 | let result = ''; 72 | 73 | module.exports.stringify(node, (bit) => { 74 | result += bit; 75 | }); 76 | 77 | return result; 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /test/parser/extend.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const { parse, nodeToString } = require('../../lib'); 4 | 5 | test('inline :extend()', (t) => { 6 | const less = '.a:extend(.b) {color: red;}'; 7 | const root = parse(less); 8 | const { first } = root; 9 | 10 | t.is(first.selector, '.a:extend(.b)'); 11 | t.truthy(first.extend); 12 | t.is(nodeToString(root), less); 13 | }); 14 | 15 | test('inline :extend() with multiple parameters', (t) => { 16 | const less = '.e:extend(.f, .g) {}'; 17 | const { first } = parse(less); 18 | 19 | t.is(first.selector, '.e:extend(.f, .g)'); 20 | t.truthy(first.extend); 21 | }); 22 | 23 | test('inline :extend() with nested selector in parameters', (t) => { 24 | const less = '.e:extend(.a .g, b span) {}'; 25 | const { first } = parse(less); 26 | 27 | t.is(first.selector, '.e:extend(.a .g, b span)'); 28 | t.truthy(first.extend); 29 | }); 30 | 31 | test('parses nested &:extend()', (t) => { 32 | const less = '.a {\n &:extend(.bucket tr);\n}'; 33 | const { first } = parse(less); 34 | 35 | t.is(first.selector, '.a'); 36 | t.is(first.first.prop, '&'); 37 | t.is(first.first.value, 'extend(.bucket tr)'); 38 | t.truthy(first.first.extend); 39 | }); 40 | 41 | test('parses :extend() after selector', (t) => { 42 | const less = 'pre:hover:extend(div pre){}'; 43 | const { first } = parse(less); 44 | 45 | t.is(first.selector, 'pre:hover:extend(div pre)'); 46 | t.truthy(first.extend); 47 | }); 48 | 49 | test('parses :extend() after selector. 2', (t) => { 50 | const less = 'pre:hover :extend(div pre){}'; 51 | const { first } = parse(less); 52 | 53 | t.is(first.selector, 'pre:hover :extend(div pre)'); 54 | t.truthy(first.extend); 55 | }); 56 | 57 | test('parses multiple extends', (t) => { 58 | const less = 'pre:hover:extend(div pre):extend(.bucket tr) { }'; 59 | const { first } = parse(less); 60 | 61 | t.is(first.selector, 'pre:hover:extend(div pre):extend(.bucket tr)'); 62 | t.truthy(first.extend); 63 | }); 64 | 65 | test('parses nth expression in extend', (t) => { 66 | const less = ':nth-child(1n+3) {color: blue;} .child:extend(:nth-child(n+3)) {}'; 67 | const root = parse(less); 68 | const { first, last } = root; 69 | 70 | t.is(first.selector, ':nth-child(1n+3)'); 71 | t.is(last.selector, '.child:extend(:nth-child(n+3))'); 72 | t.truthy(last.extend); 73 | }); 74 | 75 | test('"all"', (t) => { 76 | const less = '.replacement:extend(.test all) {}'; 77 | const { first } = parse(less); 78 | 79 | t.is(first.selector, '.replacement:extend(.test all)'); 80 | t.truthy(first.extend); 81 | }); 82 | 83 | test('with interpolation', (t) => { 84 | const less = '.bucket {color: blue;}\n.some-class:extend(@{variable}) {}\n@variable: .bucket;'; 85 | const root = parse(less); 86 | 87 | t.is(root.nodes[0].selector, '.bucket'); 88 | t.is(root.nodes[1].selector, '.some-class:extend(@{variable})'); 89 | t.truthy(root.nodes[1].extend); 90 | }); 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [tests]: https://img.shields.io/circleci/project/github/shellscape/postcss-less.svg 2 | [tests-url]: https://circleci.com/gh/shellscape/postcss-less 3 | 4 | [cover]: https://codecov.io/gh/shellscape/postcss-less/branch/master/graph/badge.svg 5 | [cover-url]: https://codecov.io/gh/shellscape/postcss-less 6 | 7 | [size]: https://packagephobia.now.sh/badge?p=postcss-less 8 | [size-url]: https://packagephobia.now.sh/result?p=postcss-less 9 | 10 | [PostCSS]: https://github.com/postcss/postcss 11 | [PostCSS-SCSS]: https://github.com/postcss/postcss-scss 12 | [LESS]: https://lesscss.org/ 13 | [Autoprefixer]: https://github.com/postcss/autoprefixer 14 | [Stylelint]: http://stylelint.io/ 15 | 16 | # postcss-less 17 | 18 | [![tests][tests]][tests-url] 19 | [![cover][cover]][cover-url] 20 | [![size][size]][size-url] 21 | 22 | A [PostCSS] Syntax for parsing [LESS] 23 | 24 | _Note: This module requires Node v6.14.4+. `poscss-less` is not a LESS compiler. For compiling LESS, please use the official toolset for LESS._ 25 | 26 | ## Install 27 | 28 | Using npm: 29 | 30 | ```console 31 | npm install postcss-less --save-dev 32 | ``` 33 | 34 | 35 | 36 | 37 | 38 | Please consider [becoming a patron](https://www.patreon.com/shellscape) if you find this module useful. 39 | 40 | ## Usage 41 | 42 | The most common use of `postcss-less` is for applying PostCSS transformations directly to LESS source. eg. ia theme written in LESS which uses [Autoprefixer] to add appropriate vendor prefixes. 43 | 44 | ```js 45 | const syntax = require('postcss-less'); 46 | postcss(plugins) 47 | .process(lessText, { syntax: syntax }) 48 | .then(function (result) { 49 | result.content // LESS with transformations 50 | }); 51 | ``` 52 | 53 | ## LESS Specific Parsing 54 | 55 | ### `@import` 56 | 57 | Parsing of LESS-specific `@import` statements and options are supported. 58 | 59 | ```less 60 | @import (option) 'file.less'; 61 | ``` 62 | 63 | The AST will contain an `AtRule` node with: 64 | 65 | - an `import: true` property 66 | - a `filename: ` property containing the imported filename 67 | - an `options: ` property containing any [import options](http://lesscss.org/features/#import-atrules-feature-import-options) specified 68 | 69 | ### Inline Comments 70 | 71 | Parsing of single-line comments in CSS is supported. 72 | 73 | ```less 74 | :root { 75 | // Main theme color 76 | --color: red; 77 | } 78 | ``` 79 | 80 | The AST will contain a `Comment` node with an `inline: true` property. 81 | 82 | ### Mixins 83 | 84 | Parsing of LESS mixins is supported. 85 | 86 | ```less 87 | .my-mixin { 88 | color: black; 89 | } 90 | ``` 91 | 92 | The AST will contain an `AtRule` node with a `mixin: true` property. 93 | 94 | #### `!important` 95 | 96 | Mixins that declare `!important` will contain an `important: true` property on their respective node. 97 | 98 | ### Variables 99 | 100 | Parsing of LESS variables is supported. 101 | 102 | ```less 103 | @link-color: #428bca; 104 | ``` 105 | 106 | The AST will contain an `AtRule` node with a `variable: true` property. 107 | 108 | _Note: LESS variables are strictly parsed - a colon (`:`) must immediately follow a variable name._ 109 | 110 | ## Stringifying 111 | 112 | To process LESS code without PostCSS transformations, custom stringifier 113 | should be provided. 114 | 115 | ```js 116 | const postcss = require('postcss'); 117 | const syntax = require('postcss-less'); 118 | 119 | const less = ` 120 | // inline comment 121 | 122 | .container { 123 | .mixin-1(); 124 | .mixin-2; 125 | .mixin-3 (@width: 100px) { 126 | width: @width; 127 | } 128 | } 129 | 130 | .rotation(@deg:5deg){ 131 | .transform(rotate(@deg)); 132 | } 133 | `; 134 | 135 | const result = await postcss().process(less, { syntax }); 136 | 137 | // will contain the value of `less` 138 | const { content } = result; 139 | ``` 140 | 141 | ## Use Cases 142 | 143 | - Lint LESS code with 3rd-party plugins. 144 | - Apply PostCSS transformations (such as [Autoprefixer](https://github.com/postcss/autoprefixer)) directly to the LESS source code 145 | 146 | ## Meta 147 | 148 | [CONTRIBUTING](./.github/CONTRIBUTING) 149 | 150 | [LICENSE (MIT)](./LICENSE) 151 | -------------------------------------------------------------------------------- /test/integration/ext.cx.dashboard.less: -------------------------------------------------------------------------------- 1 | @import '../../widgets/common/ext.cx.common.less'; 2 | @import 'mediawiki.mixins'; 3 | 4 | .cx-dashboard { 5 | color: @colorGray2; 6 | max-width: @max-dashboard-width; 7 | margin: 0 auto; 8 | padding: 10px 40px 20px; 9 | 10 | @media only screen and ( max-width: ( @narrow - 1px ) ) { 11 | padding: 10px 24px 20px; 12 | } 13 | 14 | @media only screen and ( max-width: ( @very-narrow - 1px ) ) { 15 | padding: 10px 12px 20px; 16 | } 17 | 18 | /* Clearfix */ 19 | &:after { 20 | // Non empty content value avoids an Opera bug that creates space around 21 | // clearfixed elements if the contenteditable attribute is also present 22 | // somewhere in the HTML. 23 | content: ' '; 24 | visibility: hidden; 25 | display: block; 26 | height: 0; 27 | clear: both; 28 | } 29 | } 30 | 31 | .cx-dashboard-sidebar { 32 | // Separation between sidebar (which is at the bottom on smaller screen sizes) 33 | // and bottom of the document should be 48px. 34 | // Dashboard has 20px bottom padding, and with adding 28px for bottom margin, we get desired 48px 35 | margin-bottom: 28px; 36 | line-height: 1; 37 | 38 | @media only screen and ( min-width: @wide ) { 39 | .mw-ui-one-third; 40 | padding: 0 0 0 30px; 41 | 42 | position: -webkit-sticky; 43 | position: sticky; 44 | top: 10px; 45 | } 46 | 47 | ul { 48 | margin: 0; 49 | 50 | li { 51 | list-style: none; 52 | margin: 0; 53 | 54 | // Following styles should support possible additions to help card 55 | &:not( :last-child ) { 56 | margin-bottom: 16px; 57 | } 58 | 59 | &:first-child { 60 | padding-top: 4px; 61 | } 62 | 63 | &:last-child { 64 | padding-bottom: 4px; 65 | } 66 | } 67 | } 68 | 69 | &__link, 70 | &__link:visited { 71 | background-position: center left; 72 | background-repeat: no-repeat; 73 | background-size: 16px; 74 | color: @colorProgressive; 75 | padding-left: 24px; 76 | } 77 | 78 | &__link { 79 | &--information { 80 | .background-image-svg('../images/cx-information.svg', '../images/cx-information.png'); 81 | } 82 | 83 | &--stats { 84 | .background-image-svg('../images/cx-stats.svg', '../images/cx-stats.png'); 85 | } 86 | 87 | &--feedback { 88 | .background-image-svg('../images/cx-discuss.svg', '../images/cx-discuss.png'); 89 | } 90 | } 91 | 92 | &__help { 93 | background-color: #fff; 94 | .box-sizing( border-box ); 95 | border-radius: @borderRadius; 96 | padding: 16px; 97 | .box-shadow-card; 98 | font-size: 16px; 99 | 100 | .cx-translator--visible ~ & { 101 | @media only screen and ( min-width: @narrow ) and ( max-width: ( @wide - 1px ) ) { 102 | display: inline-block; 103 | width: 47.5%; 104 | margin-left: 5%; 105 | vertical-align: top; 106 | } 107 | } 108 | 109 | &-title { 110 | color: @colorGray5; 111 | margin-bottom: 16px; 112 | font-weight: bold; 113 | } 114 | } 115 | } 116 | 117 | .cx-translationlist-container { 118 | background-color: @colorGray14; 119 | margin-bottom: 48px; 120 | padding: 0; 121 | 122 | @media only screen and ( min-width: @wide ) { 123 | .mw-ui-two-thirds; 124 | padding: 0; 125 | // This is needed so sidebar doesn't wiggle 126 | width: 66.667%; 127 | } 128 | } 129 | 130 | .translation-filter { 131 | background-color: @colorGray14; 132 | padding: 10px 5px 20px 5px; 133 | margin: -10px -5px 0 -5px; 134 | position: -webkit-sticky; 135 | position: sticky; 136 | top: 0; 137 | z-index: 1; 138 | 139 | .flex-center-justify; 140 | 141 | > .oo-ui-buttonWidget > .oo-ui-buttonElement-button { 142 | height: 2.34375em; 143 | padding-right: 0.46875em; 144 | 145 | @media only screen and ( min-width: 380px ) { 146 | height: auto; 147 | padding-right: 0.9375em; 148 | } 149 | 150 | > .oo-ui-labelElement-label { 151 | // Hide text and show only '+' icon on "New translation" button for screen sizes below 380px 152 | display: none; 153 | 154 | @media only screen and ( min-width: 380px ) { 155 | display: inline; 156 | } 157 | } 158 | } 159 | } 160 | 161 | .oo-ui-popupWidget { 162 | // We need popup widgets to stay above .translation-filter 163 | z-index: 2; 164 | } 165 | 166 | .cx-header-infobar { 167 | float: none; 168 | max-width: @max-dashboard-width; 169 | margin: 0 auto; 170 | padding: 0 40px; 171 | 172 | @media only screen and ( max-width: ( @narrow - 1px ) ) { 173 | padding: 0 24px; 174 | } 175 | 176 | @media only screen and ( max-width: ( @very-narrow - 1px ) ) { 177 | padding: 0 12px; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /lib/LessParser.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies, no-param-reassign */ 2 | 3 | const Comment = require('postcss/lib/comment'); 4 | const Parser = require('postcss/lib/parser'); 5 | 6 | const { isInlineComment } = require('./nodes/inline-comment'); 7 | const { interpolation } = require('./nodes/interpolation'); 8 | const { isMixinToken } = require('./nodes/mixin'); 9 | const importNode = require('./nodes/import'); 10 | const variableNode = require('./nodes/variable'); 11 | 12 | const importantPattern = /(!\s*important)$/i; 13 | 14 | module.exports = class LessParser extends Parser { 15 | constructor(...args) { 16 | super(...args); 17 | 18 | this.lastNode = null; 19 | } 20 | 21 | atrule(token) { 22 | if (interpolation.bind(this)(token)) { 23 | return; 24 | } 25 | 26 | super.atrule(token); 27 | importNode(this.lastNode); 28 | variableNode(this.lastNode); 29 | } 30 | 31 | decl(...args) { 32 | super.decl(...args); 33 | 34 | // #123: add `extend` decorator to nodes 35 | const extendPattern = /extend\(.+\)/i; 36 | 37 | if (extendPattern.test(this.lastNode.value)) { 38 | this.lastNode.extend = true; 39 | } 40 | } 41 | 42 | each(tokens) { 43 | // prepend a space so the `name` will be parsed correctly 44 | tokens[0][1] = ` ${tokens[0][1]}`; 45 | 46 | const firstParenIndex = tokens.findIndex((t) => t[0] === '('); 47 | const lastParen = tokens.reverse().find((t) => t[0] === ')'); 48 | const lastParenIndex = tokens.reverse().indexOf(lastParen); 49 | const paramTokens = tokens.splice(firstParenIndex, lastParenIndex); 50 | const params = paramTokens.map((t) => t[1]).join(''); 51 | 52 | for (const token of tokens.reverse()) { 53 | this.tokenizer.back(token); 54 | } 55 | 56 | this.atrule(this.tokenizer.nextToken()); 57 | this.lastNode.function = true; 58 | this.lastNode.params = params; 59 | } 60 | 61 | init(node, line, column) { 62 | super.init(node, line, column); 63 | this.lastNode = node; 64 | } 65 | 66 | inlineComment(token) { 67 | const node = new Comment(); 68 | const text = token[1].slice(2); 69 | 70 | this.init(node, token[2]); 71 | node.source.end = this.getPosition(token[3] || token[2]); 72 | node.inline = true; 73 | node.raws.begin = '//'; 74 | 75 | if (/^\s*$/.test(text)) { 76 | node.text = ''; 77 | node.raws.left = text; 78 | node.raws.right = ''; 79 | } else { 80 | const match = text.match(/^(\s*)([^]*[^\s])(\s*)$/); 81 | [, node.raws.left, node.text, node.raws.right] = match; 82 | } 83 | } 84 | 85 | mixin(tokens) { 86 | const [first] = tokens; 87 | const identifier = first[1].slice(0, 1); 88 | const bracketsIndex = tokens.findIndex((t) => t[0] === 'brackets'); 89 | const firstParenIndex = tokens.findIndex((t) => t[0] === '('); 90 | let important = ''; 91 | 92 | // fix for #86. if rulesets are mixin params, they need to be converted to a brackets token 93 | if ((bracketsIndex < 0 || bracketsIndex > 3) && firstParenIndex > 0) { 94 | const lastParenIndex = tokens.reduce((last, t, i) => (t[0] === ')' ? i : last)); 95 | 96 | const contents = tokens.slice(firstParenIndex, lastParenIndex + firstParenIndex); 97 | const brackets = contents.map((t) => t[1]).join(''); 98 | const [paren] = tokens.slice(firstParenIndex); 99 | const start = [paren[2], paren[3]]; 100 | const [last] = tokens.slice(lastParenIndex, lastParenIndex + 1); 101 | const end = [last[2], last[3]]; 102 | const newToken = ['brackets', brackets].concat(start, end); 103 | 104 | const tokensBefore = tokens.slice(0, firstParenIndex); 105 | const tokensAfter = tokens.slice(lastParenIndex + 1); 106 | tokens = tokensBefore; 107 | tokens.push(newToken); 108 | tokens = tokens.concat(tokensAfter); 109 | } 110 | 111 | const importantTokens = []; 112 | 113 | for (const token of tokens) { 114 | if (token[1] === '!' || importantTokens.length) { 115 | importantTokens.push(token); 116 | } 117 | 118 | if (token[1] === 'important') { 119 | break; 120 | } 121 | } 122 | 123 | if (importantTokens.length) { 124 | const [bangToken] = importantTokens; 125 | const bangIndex = tokens.indexOf(bangToken); 126 | const last = importantTokens[importantTokens.length - 1]; 127 | const start = [bangToken[2], bangToken[3]]; 128 | const end = [last[4], last[5]]; 129 | const combined = importantTokens.map((t) => t[1]).join(''); 130 | const newToken = ['word', combined].concat(start, end); 131 | tokens.splice(bangIndex, importantTokens.length, newToken); 132 | } 133 | 134 | const importantIndex = tokens.findIndex((t) => importantPattern.test(t[1])); 135 | 136 | if (importantIndex > 0) { 137 | [, important] = tokens[importantIndex]; 138 | tokens.splice(importantIndex, 1); 139 | } 140 | 141 | for (const token of tokens.reverse()) { 142 | this.tokenizer.back(token); 143 | } 144 | 145 | this.atrule(this.tokenizer.nextToken()); 146 | this.lastNode.mixin = true; 147 | this.lastNode.raws.identifier = identifier; 148 | 149 | if (important) { 150 | this.lastNode.important = true; 151 | this.lastNode.raws.important = important; 152 | } 153 | } 154 | 155 | other(token) { 156 | if (!isInlineComment.bind(this)(token)) { 157 | super.other(token); 158 | } 159 | } 160 | 161 | rule(tokens) { 162 | const last = tokens[tokens.length - 1]; 163 | const prev = tokens[tokens.length - 2]; 164 | 165 | if (prev[0] === 'at-word' && last[0] === '{') { 166 | this.tokenizer.back(last); 167 | if (interpolation.bind(this)(prev)) { 168 | const newToken = this.tokenizer.nextToken(); 169 | 170 | tokens = tokens.slice(0, tokens.length - 2).concat([newToken]); 171 | 172 | for (const tokn of tokens.reverse()) { 173 | this.tokenizer.back(tokn); 174 | } 175 | 176 | return; 177 | } 178 | } 179 | 180 | super.rule(tokens); 181 | 182 | // #123: add `extend` decorator to nodes 183 | const extendPattern = /:extend\(.+\)/i; 184 | 185 | if (extendPattern.test(this.lastNode.selector)) { 186 | this.lastNode.extend = true; 187 | } 188 | } 189 | 190 | unknownWord(tokens) { 191 | // NOTE: keep commented for examining unknown structures 192 | // console.log('unknown', tokens); 193 | 194 | const [first] = tokens; 195 | 196 | // #121 support `each` - http://lesscss.org/functions/#list-functions-each 197 | if (tokens[0][1] === 'each' && tokens[1][0] === '(') { 198 | this.each(tokens); 199 | return; 200 | } 201 | 202 | // TODO: move this into a util function/file 203 | if (isMixinToken(first)) { 204 | this.mixin(tokens); 205 | return; 206 | } 207 | 208 | super.unknownWord(tokens); 209 | } 210 | }; 211 | -------------------------------------------------------------------------------- /test/parser/comments.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Comment = require('postcss/lib/comment'); 3 | 4 | const { parse, nodeToString } = require('../../lib'); 5 | 6 | test('inline comment', (t) => { 7 | const less = '// batman'; 8 | const root = parse(less); 9 | const { first } = root; 10 | 11 | t.truthy(root); 12 | t.true(first instanceof Comment); 13 | t.true(first.inline); 14 | t.is(first.text, 'batman'); 15 | t.is(nodeToString(root), less); 16 | }); 17 | 18 | test('two-line inline comment with a single quote and windows EOL', (t) => { 19 | const less = `// it's first comment (this line should end with Windows new line symbols)\r\n// it's second comment`; 20 | const root = parse(less); 21 | const [first, second, ...rest] = root.nodes; 22 | 23 | t.truthy(root); 24 | t.true(first instanceof Comment); 25 | t.true(first.inline); 26 | t.is(first.text, `it's first comment (this line should end with Windows new line symbols)`); 27 | t.true(second instanceof Comment); 28 | t.true(second.inline); 29 | t.is(second.text, `it's second comment`); 30 | t.is(nodeToString(root), less); 31 | t.is(rest.length, 0); 32 | }); 33 | 34 | test('inline comment without leading space', (t) => { 35 | const less = '//batman'; 36 | const root = parse(less); 37 | const { first } = root; 38 | 39 | t.truthy(root); 40 | t.true(first instanceof Comment); 41 | t.true(first.inline); 42 | t.is(first.text, 'batman'); 43 | t.is(nodeToString(root), less); 44 | }); 45 | 46 | test('inline comment with unclosed characters', (t) => { 47 | const less = `// unclosed ' test { test ( test /*`; 48 | const root = parse(less); 49 | const { first } = root; 50 | 51 | t.truthy(root); 52 | t.true(first instanceof Comment); 53 | t.true(first.inline); 54 | t.is(first.text, less.slice(3)); 55 | t.is(nodeToString(root), less); 56 | }); 57 | 58 | test('inline comment with unclosed characters and future statements', (t) => { 59 | const less = `// unclosed ' test { test ( test /* test "\nfoo: 'bar';\nbar: "foo"\nabc: (def)\n foo{}`; 60 | const root = parse(less); 61 | const { first } = root; 62 | 63 | t.truthy(root); 64 | t.true(first instanceof Comment); 65 | t.true(first.inline); 66 | // first.text should only contain the comment line without the `// ` 67 | t.is(first.text, less.substring(0, less.indexOf('\n')).slice(3)); 68 | t.is(nodeToString(root), less); 69 | }); 70 | 71 | test('inline comment with closed characters and future statements', (t) => { 72 | const less = `// closed '' test {} test () test /* */ test "\nfoo: 'bar';\nbar: "foo"\nabc: (def)\n foo{}`; 73 | const root = parse(less); 74 | const { first } = root; 75 | 76 | t.truthy(root); 77 | t.true(first instanceof Comment); 78 | t.true(first.inline); 79 | // first.text should only contain the comment line without the `// ` 80 | t.is(first.text, less.substring(0, less.indexOf('\n')).slice(3)); 81 | t.is(nodeToString(root), less); 82 | }); 83 | 84 | test('two subsequent inline comments with unmatched quotes', (t) => { 85 | const less = `// first unmatched '\n// second ' unmatched\nfoo: 'bar';`; 86 | const root = parse(less); 87 | const [firstComment, secondComment] = root.nodes; 88 | 89 | t.truthy(root); 90 | t.true(firstComment instanceof Comment); 91 | t.true(secondComment instanceof Comment); 92 | t.true(firstComment.inline); 93 | t.true(secondComment.inline); 94 | // firstComment.text & secondComment.text should only contain the comment line without the `// ` 95 | const lines = less.split('\n'); 96 | t.is(firstComment.text, lines[0].slice(3)); 97 | t.is(secondComment.text, lines[1].slice(3)); 98 | t.is(nodeToString(root), less); 99 | }); 100 | 101 | test('inline comments with quotes and new line(#145)', (t) => { 102 | const less = ` 103 | // batman' 104 | `; 105 | 106 | const root = parse(less); 107 | const { first } = root; 108 | 109 | t.is(first.type, 'comment'); 110 | t.is(first.text, `batman'`); 111 | t.is(nodeToString(root), less); 112 | }); 113 | 114 | test('close empty', (t) => { 115 | const less = '// \n//'; 116 | const root = parse(less); 117 | const { first, last } = root; 118 | 119 | t.truthy(root.nodes.length); 120 | t.is(first.text, ''); 121 | t.is(last.text, ''); 122 | t.is(nodeToString(root), less); 123 | }); 124 | 125 | test('close inline and block', (t) => { 126 | const less = '// batman\n/* cleans the batcave */'; 127 | const root = parse(less); 128 | const { first, last } = root; 129 | 130 | t.truthy(root.nodes.length); 131 | t.is(first.text, 'batman'); 132 | t.is(last.text, 'cleans the batcave'); 133 | t.is(nodeToString(root), less); 134 | }); 135 | 136 | test('parses multiline comments', (t) => { 137 | const text = 'batman\n robin\n joker'; 138 | const less = ` /* ${text} */ `; 139 | const root = parse(less); 140 | const { first } = root; 141 | 142 | t.is(root.nodes.length, 1); 143 | t.is(first.text, text); 144 | t.deepEqual(first.raws, { 145 | before: ' ', 146 | left: ' ', 147 | right: ' ' 148 | }); 149 | t.falsy(first.inline); 150 | t.is(nodeToString(root), less); 151 | }); 152 | 153 | test('ignores pseudo-comments constructions', (t) => { 154 | const less = 'a { cursor: url(http://site.com) }'; 155 | const root = parse(less); 156 | const { first } = root; 157 | 158 | t.false(first instanceof Comment); 159 | t.is(nodeToString(root), less); 160 | }); 161 | 162 | test('newlines are put on the next node', (t) => { 163 | const less = '// a comment\n.a {}'; 164 | 165 | const root = parse(less); 166 | const { first, last } = root; 167 | 168 | t.is(first.raws.right, ''); 169 | t.is(last.raws.before, '\n'); 170 | }); 171 | 172 | test('inline comments with asterisk are persisted (#135)', (t) => { 173 | const less = '//*batman'; 174 | 175 | const root = parse(less); 176 | const { first } = root; 177 | 178 | t.is(first.type, 'comment'); 179 | t.is(first.text, '*batman'); 180 | t.is(nodeToString(root), less); 181 | }); 182 | 183 | test('handles single quotes in comments (#163)', (t) => { 184 | const less = `a {\n // '\n color: pink;\n}\n\n/** ' */`; 185 | 186 | const root = parse(less); 187 | 188 | const [ruleNode, commentNode] = root.nodes; 189 | 190 | t.is(ruleNode.type, 'rule'); 191 | t.is(commentNode.type, 'comment'); 192 | 193 | t.is(commentNode.source.start.line, 6); 194 | t.is(commentNode.source.start.column, 1); 195 | t.is(commentNode.source.end.line, 6); 196 | t.is(commentNode.source.end.column, 8); 197 | 198 | const [innerCommentNode, declarationNode] = ruleNode.nodes; 199 | 200 | t.is(innerCommentNode.type, 'comment'); 201 | t.is(declarationNode.type, 'decl'); 202 | 203 | t.is(innerCommentNode.source.start.line, 2); 204 | t.is(innerCommentNode.source.start.column, 3); 205 | t.is(innerCommentNode.source.end.line, 2); 206 | t.is(innerCommentNode.source.end.column, 6); 207 | 208 | t.is(declarationNode.source.start.line, 3); 209 | t.is(declarationNode.source.start.column, 3); 210 | t.is(declarationNode.source.end.line, 3); 211 | t.is(declarationNode.source.end.column, 14); 212 | 213 | t.is(nodeToString(root), less); 214 | }); 215 | -------------------------------------------------------------------------------- /test/parser/mixins.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const { parse, nodeToString } = require('../../lib'); 4 | 5 | test('basic', (t) => { 6 | const params = '( 1, 2, 3; something, else; ...) when (@mode=huge)'; 7 | const selector = `.foo ${params}`; 8 | const less = `${selector} { border: baz; }`; 9 | const root = parse(less); 10 | const { first } = root; 11 | 12 | t.is(first.type, 'rule'); 13 | t.is(first.selector, selector); 14 | t.is(first.first.prop, 'border'); 15 | t.is(first.first.value, 'baz'); 16 | t.is(nodeToString(root), less); 17 | }); 18 | 19 | test('without body', (t) => { 20 | const less = '.mixin-name (#FFF);'; 21 | const root = parse(less); 22 | const { first } = root; 23 | 24 | t.is(first.type, 'atrule'); 25 | t.is(first.name, 'mixin-name'); 26 | t.is(first.params, '(#FFF)'); 27 | t.falsy(first.nodes); 28 | t.is(nodeToString(root), less); 29 | }); 30 | 31 | test('without body, no params', (t) => { 32 | const less = '.base { .mixin-name }'; 33 | const root = parse(less); 34 | const { first } = root; 35 | 36 | t.is(first.first.type, 'atrule'); 37 | t.is(first.first.name, 'mixin-name'); 38 | t.falsy(first.params); 39 | t.falsy(first.first.nodes); 40 | t.is(nodeToString(root), less); 41 | }); 42 | 43 | test('without body, no params, no whitepsace', (t) => { 44 | const less = '.base {.mixin-name}'; 45 | const root = parse(less); 46 | const { first } = root; 47 | 48 | t.is(first.first.type, 'atrule'); 49 | t.is(first.first.name, 'mixin-name'); 50 | t.falsy(first.params); 51 | t.falsy(first.first.nodes); 52 | t.is(nodeToString(root), less); 53 | }); 54 | 55 | // TODO: false positive, this is valid standard CSS syntax 56 | // test('simple with params', async (t) => { 57 | // const less = '.foo (@bar; @baz...) { border: baz; }'; 58 | // const { first } = parse(less); 59 | // 60 | // t.is(first.type, 'atrule'); 61 | // }); 62 | 63 | /* eslint-disable no-multiple-empty-lines */ 64 | test('class and id selectors', (t) => { 65 | const less = ` 66 | .mixin-class { 67 | .a(#FFF); 68 | } 69 | .mixin-id { 70 | #b (@param1; @param2); 71 | } 72 | 73 | .class { 74 | .mixin1 ( 75 | 76 | 77 | ) 78 | 79 | 80 | ; 81 | 82 | .mixin2 83 | } 84 | `; 85 | 86 | const root = parse(less); 87 | const { 88 | nodes: [first, second, last] 89 | } = root; 90 | 91 | t.is(first.first.name, 'a'); 92 | t.is(first.first.params, '(#FFF)'); 93 | 94 | t.is(second.first.name, 'b'); 95 | t.is(second.first.params, '(@param1; @param2)'); 96 | 97 | t.is(last.first.name, 'mixin1'); 98 | t.true(/\(\s+\)/.test(last.first.params)); 99 | }); 100 | 101 | test('namespaces', (t) => { 102 | const less = ` 103 | .c { 104 | #outer > .inner; 105 | #space > .importer-1(); 106 | } 107 | `; 108 | 109 | const root = parse(less); 110 | const { first } = root; 111 | 112 | t.is(first.selector, '.c'); 113 | t.is(first.nodes.length, 2); 114 | t.is(nodeToString(root), less); 115 | }); 116 | 117 | test('guarded namespaces', (t) => { 118 | const less = ` 119 | #namespace when (@mode=huge) { 120 | .mixin() { /* */ } 121 | } 122 | 123 | #namespace { 124 | .mixin() when (@mode=huge) { /* */ } 125 | } 126 | `; 127 | 128 | const root = parse(less); 129 | const { first } = root; 130 | 131 | t.is(first.first.selector, '.mixin()'); 132 | t.is(first.next().first.selector, '.mixin() when (@mode=huge)'); 133 | t.is(nodeToString(root), less); 134 | }); 135 | 136 | test('mixins with `!important`', (t) => { 137 | const less = ` 138 | .foo() !important; 139 | `; 140 | 141 | const root = parse(less); 142 | const { first } = root; 143 | 144 | t.is(first.name, 'foo'); 145 | t.is(first.params, '()'); 146 | t.is(first.important, true); 147 | t.is(nodeToString(root), less); 148 | }); 149 | 150 | test('case-insensitive !important', (t) => { 151 | const less = ` 152 | .foo() !IMPoRTant; 153 | `; 154 | 155 | const root = parse(less); 156 | const { first } = root; 157 | 158 | t.is(first.name, 'foo'); 159 | t.is(first.params, '()'); 160 | t.is(first.important, true); 161 | t.is(nodeToString(root), less); 162 | }); 163 | 164 | test('!important, no whitespace', (t) => { 165 | const less = ` 166 | .foo()!important; 167 | `; 168 | 169 | const root = parse(less); 170 | const { first } = root; 171 | 172 | t.is(first.name, 'foo'); 173 | t.is(first.params, '()'); 174 | t.is(first.important, true); 175 | t.is(nodeToString(root), less); 176 | }); 177 | 178 | test('!important, whitespace between', (t) => { 179 | const less = ` 180 | .foo()! important; 181 | `; 182 | const root = parse(less); 183 | const { first } = root; 184 | t.is(first.name, 'foo'); 185 | t.is(first.params, '()'); 186 | t.is(first.important, true); 187 | t.is(nodeToString(root), less); 188 | }); 189 | 190 | test('!important, whitespace before and between', (t) => { 191 | const less = ` 192 | .foo() ! important; 193 | `; 194 | const root = parse(less); 195 | const { first } = root; 196 | t.is(first.name, 'foo'); 197 | t.is(first.params, '()'); 198 | t.is(first.important, true); 199 | t.is(nodeToString(root), less); 200 | }); 201 | 202 | test('parses nested mixins with the rule set', (t) => { 203 | const params = '({background-color: red;})'; 204 | const ruleSet = `.desktop-and-old-ie ${params}`; 205 | const less = `header { ${ruleSet}; }`; 206 | const root = parse(less); 207 | const { first } = root; 208 | 209 | t.is(first.selector, 'header'); 210 | t.is(first.first.name, 'desktop-and-old-ie'); 211 | t.is(first.first.params, params); 212 | t.falsy(first.first.nodes); 213 | t.is(nodeToString(root), less); 214 | }); 215 | 216 | test('should parse nested mixin', (t) => { 217 | const less = ` 218 | .badge-quality { 219 | &:extend(.label, .m_text-uppercase); 220 | font-size: 85%; 221 | font-weight: normal; 222 | min-width: 2rem; 223 | height: 2rem; 224 | padding: 0.2rem 0.3rem; 225 | display: inline-block; 226 | border-radius: 0; 227 | cursor: default; 228 | line-height: 1.6rem; 229 | color: @c_white; 230 | background-color: @c_blue1; 231 | 232 | &_info { 233 | background-color: @c_blue5; 234 | } 235 | 236 | &_danger { 237 | background-color: @c_red2; 238 | } 239 | 240 | &_success { 241 | background-color: @c_green3; 242 | } 243 | 244 | &_warning { 245 | background-color: @c_yellow1; 246 | color: @c_black1; 247 | } 248 | } 249 | 250 | .badge-category { 251 | &:extend(.m_badge-default); 252 | } 253 | 254 | .buy-sell-badge { 255 | .m_text-uppercase(); 256 | 257 | &_buy { 258 | &:extend(.m_text-success); 259 | } 260 | 261 | &_sell { 262 | &:extend(.m_text-error); 263 | } 264 | } 265 | `; 266 | 267 | const root = parse(less); 268 | 269 | t.is(root.nodes.length, 3); 270 | t.is(nodeToString(root), less); 271 | }); 272 | 273 | test('mixin missing semicolon (#110)', (t) => { 274 | const less = '.foo{.bar("baz")}'; 275 | const root = parse(less); 276 | const { first } = root; 277 | 278 | t.is(first.first.name, 'bar'); 279 | t.is(nodeToString(root), less); 280 | }); 281 | 282 | test('important in parameters (#102)', (t) => { 283 | const less = `.f( 284 | @a : { 285 | color : red !important; 286 | background : blue; 287 | } 288 | );`; 289 | const root = parse(less); 290 | const { first } = root; 291 | 292 | t.falsy(first.important); 293 | t.is(nodeToString(root), less); 294 | }); 295 | 296 | test('mixin parameters with functions (#122)', (t) => { 297 | const less = `.mixin({ 298 | 0% { 299 | transform: rotate(0deg); 300 | } 301 | 100% { 302 | transform: rotate(360deg); 303 | } 304 | });`; 305 | const root = parse(less); 306 | const { first } = root; 307 | 308 | t.is(first.name, 'mixin'); 309 | t.is(nodeToString(root), less); 310 | }); 311 | 312 | test('mixin parameters with multiple parens', (t) => { 313 | const less = `.mixin({ 314 | &__icon { 315 | background-image: url('./icon.svg'); 316 | width: calc(~"100% + 1px"); 317 | } 318 | }); 319 | .two {}`; 320 | 321 | const root = parse(less); 322 | const { first, last } = root; 323 | 324 | t.is(first.name, 'mixin'); 325 | t.is(last.selector, '.two'); 326 | t.is(nodeToString(root), less); 327 | }); 328 | --------------------------------------------------------------------------------