From c256b39c22b2420366509f9b84e27e5a88297107 Mon Sep 17 00:00:00 2001 From: Roman Dvornov Date: Tue, 14 Dec 2021 18:08:28 +0100 Subject: [PATCH] Add `syntaxExtensions` option --- CHANGELOG.md | 1 + README.md | 31 +++++++++- lib/index.js | 91 ++++++++++++++++++++--------- test/{index.js => css.js} | 120 ++++++++++++-------------------------- test/preprocessors.js | 82 ++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 113 deletions(-) rename test/{index.js => css.js} (55%) create mode 100644 test/preprocessors.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 646f945..1fa1c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Added at-rule validation for name, prelude and descriptors - Added `atrules` option to extend or alter at-rule syntax definition dictionary or disable at-rule validation when `false` is passed as a value for the option - Added `ignoreAtrules` option to specify at-rule names which should not be validated +- Added `syntaxExtensions` option to specify syntax extensions, i.e. `sass` or/and `less` - Used `isStandardSyntax*()` helpers from `stylelint` to reduce failures for non-standard syntax (e.g. Less or Sass) - Added support for Less & Sass namespaces, a value with such constructions are ignored now instead of failure (#39) - Added a column to mismatch error details diff --git a/README.md b/README.md index 6de1027..6b03342 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A [stylelint](http://stylelint.io/) plugin based on [csstree](https://github.com/csstree/csstree) to examinate CSS syntax. It examinates at-rules and declaration values to match W3C specs and browsers extensions. It might be extended in future to validate other parts of CSS. -> ⚠️ Warning ⚠️: The plugin is designed to validate CSS syntax only. However `stylelint` may be configured to use for other syntaxes like Less or Sass. In this case, the plugin avoids examination of expressions containing non-standard syntax. +> ⚠️ Warning ⚠️: The plugin is designed to validate CSS syntax only. However `stylelint` may be configured to use for other syntaxes like Less or Sass. In this case, the plugin avoids examination of expressions containing non-standard syntax, but you need specify which preprocessor is used with the [`syntaxExtensions`](#syntaxextensions) option. ## Install @@ -31,13 +31,42 @@ Setup plugin in [stylelint config](http://stylelint.io/user-guide/configuration/ ### Options +- [syntaxExtensions](#syntaxextensions) - [atrules](#atrules) - [properties](#properties) - [types](#types) - [ignore](#ignore) +- [ignoreAtrules](#ignoreatrules) - [ignoreProperties](#ignoreproperties) - [ignoreValue](#ignorevalue) +#### syntaxExtensions + +Type: `Array<'sass' | 'less'>` or `false` +Default: `false` + +Since the plugin focuses on CSS syntax validation it warns on a syntax which is introducing by preprocessors like Less or Sass. The `syntaxExtensions` option allows to specify that some preprocessor's syntaxes are used for styles so the plugin may avoid warnings when met such a syntax. + +By default the plugin exams styles as pure CSS. To specify that a preprocessor's syntax is used, you must specify an array with the names of these extensions. Currently supported: + +- `sass` – declaration values with Sass syntax will be ignored as well as custom at-rules introduced by Saas (e.g. `@if`, `@else`, `@mixin` etc) +- `less` – declaration values with Sass syntax will be ignored as well as `@plugin` at-rule introduced by Less + +Using both syntax extensions is also possible: + +```json +{ + "plugins": [ + "stylelint-csstree-validator" + ], + "rules": { + "csstree/validator": { + "syntaxExtensions": ["sass", "less"] + } + } +} +``` + #### atrules Type: `Object`, `false` or `null` diff --git a/lib/index.js b/lib/index.js index 56666a0..b001958 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,37 +3,49 @@ import isStandardSyntaxAtRule from 'stylelint/lib/utils/isStandardSyntaxAtRule.j import isStandardSyntaxDeclaration from 'stylelint/lib/utils/isStandardSyntaxDeclaration.js'; import isStandardSyntaxProperty from 'stylelint/lib/utils/isStandardSyntaxProperty.js'; import isStandardSyntaxValue from 'stylelint/lib/utils/isStandardSyntaxValue.js'; -import { fork } from 'css-tree'; +import { fork, lexer, parse } from 'css-tree'; import { less, sass } from './syntax-extension/index.js'; const { utils, createPlugin } = stylelint; -const csstree = fork(less).fork(sass); const isRegExp = value => toString.call(value) === '[object RegExp]'; const getRaw = (node, name) => (node.raws && node.raws[name]) || ''; +const allowedSyntaxExtensions = new Set(['less', 'sass']); +const lessExtendedSyntax = fork(less); +const sassExtendedSyntax = fork(sass); +const syntaxExtensions = { + none: { fork, lexer, parse }, + less: lessExtendedSyntax, + sass: sassExtendedSyntax, + all: fork(less).fork(sass) +}; const ruleName = 'csstree/validator'; const messages = utils.ruleMessages(ruleName, { - parseError: function(value) { + csstree(value) { + return value; + }, + parseError(value) { return 'Can\'t parse value "' + value + '"'; }, - invalidValue: function(property) { - return 'Invalid value for `' + property + '`'; + unknownAtrule(atrule) { + return 'Unknown at-rule `@' + atrule + '`'; }, - invalidPrelude: function(atrule) { + invalidPrelude(atrule) { return 'Invalid prelude for `@' + atrule + '`'; + }, + unknownProperty(property) { + return 'Unknown property `' + property + '`'; + }, + invalidValue(property) { + return 'Invalid value for "' + property + '"'; } }); -const seenOptions = new WeakSet(); const plugin = createPlugin(ruleName, function(options) { options = options || {}; const optionIgnoreProperties = options.ignoreProperties || options.ignore; - - if (options.ignore && !seenOptions.has(options)) { - console.warn(`[${ruleName}] "ignore" option is deprecated and will be removed in future versions, use "ignoreProperties" instead`); - seenOptions.add(options); - } + const optionSyntaxExtension = new Set(Array.isArray(options.syntaxExtensions) ? options.syntaxExtensions : []); const ignoreValue = options.ignoreValue && (typeof options.ignoreValue === 'string' || isRegExp(options.ignoreValue)) ? new RegExp(options.ignoreValue) @@ -45,9 +57,16 @@ const plugin = createPlugin(ruleName, function(options) { ? new Set(options.ignoreAtrules.map(name => String(name).toLowerCase())) : false; const atrulesValidationDisabled = options.atrules === false; - const syntax = !options.properties && !options.types && !options.atrules - ? csstree.lexer // default syntax - : csstree.fork({ + const syntax = optionSyntaxExtension.has('less') + ? optionSyntaxExtension.has('sass') + ? syntaxExtensions.all + : syntaxExtensions.less + : optionSyntaxExtension.has('sass') + ? syntaxExtensions.sass + : syntaxExtensions.none; + const lexer = !options.properties && !options.types && !options.atrules + ? syntax.lexer // default syntax + : syntax.fork({ properties: options.properties, types: options.types, atrules: options.atrules @@ -56,11 +75,22 @@ const plugin = createPlugin(ruleName, function(options) { return function(root, result) { const ignoreAtruleNodes = new WeakSet(); + stylelint.utils.validateOptions(result, ruleName, { + actual: { + ignore: options.ignore, + syntaxExtensions: [...optionSyntaxExtension] + }, + possible: { + ignore: value => value === undefined, + syntaxExtensions: value => allowedSyntaxExtensions.has(value) + } + }); + root.walkAtRules(function(atrule) { let error; // ignore non-standard at-rules - if (!isStandardSyntaxAtRule(atrule)) { + if (syntax !== syntaxExtensions.none && !isStandardSyntaxAtRule(atrule)) { return; } @@ -75,25 +105,27 @@ const plugin = createPlugin(ruleName, function(options) { return; } - if (error = syntax.checkAtruleName(atrule.name)) { + if (error = lexer.checkAtruleName(atrule.name)) { ignoreAtruleNodes.add(atrule); utils.report({ ruleName, result, - message: error.message, + message: messages.csstree(error.message), node: atrule }); return; } - if (error = syntax.matchAtrulePrelude(atrule.name, atrule.params).error) { + if (error = lexer.matchAtrulePrelude(atrule.name, atrule.params).error) { let message = error.rawMessage || error.message; let index = 2 + atrule.name.length + getRaw('afterName').length; if (message === 'Mismatch') { message = messages.invalidPrelude(atrule.name); index += error.mismatchOffset; + } else { + message = messages.csstree(message); } utils.report({ @@ -118,16 +150,17 @@ const plugin = createPlugin(ruleName, function(options) { } // ignore declarations with non-standard syntax (Less, Sass, etc) - if (!isStandardSyntaxDeclaration(decl) || - !isStandardSyntaxProperty(decl.prop) || - !isStandardSyntaxValue(decl.value)) { - return; + if (syntax !== syntaxExtensions.none) { + if (!isStandardSyntaxDeclaration(decl) || + !isStandardSyntaxProperty(decl.prop) || + !isStandardSyntaxValue(decl.value)) { + return; + } } try { - csstree.parse(decl.value, { - context: 'value', - property: decl.prop + syntax.parse(decl.value, { + context: 'value' }); } catch (e) { // ignore values with preprocessor's extensions @@ -149,8 +182,8 @@ const plugin = createPlugin(ruleName, function(options) { } const { error } = decl.parent.type === 'atrule' - ? syntax.matchAtruleDescriptor(decl.parent.name, decl.prop, decl.value) - : syntax.matchProperty(decl.prop, decl.value); + ? lexer.matchAtruleDescriptor(decl.parent.name, decl.prop, decl.value) + : lexer.matchProperty(decl.prop, decl.value); if (error) { let message = error.rawMessage || error.message || error; @@ -170,6 +203,8 @@ const plugin = createPlugin(ruleName, function(options) { message = messages.invalidValue(decl.prop); index = decl.prop.length + getRaw(decl, 'between').length + error.mismatchOffset; + } else { + message = messages.csstree(message); } utils.report({ diff --git a/test/index.js b/test/css.js similarity index 55% rename from test/index.js rename to test/css.js index aa02d1b..fbef67c 100644 --- a/test/index.js +++ b/test/css.js @@ -1,22 +1,20 @@ import validator from 'stylelint-csstree-validator'; -import lessSyntax from 'postcss-less'; -import scssSyntax from 'postcss-scss'; import ruleTester from './utils/tester.js'; const { rule, ruleName, messages } = validator; const css = ruleTester(rule, ruleName); -const less = ruleTester(rule, ruleName, { - postcssOptions: { syntax: lessSyntax } -}); -const sass = ruleTester(rule, ruleName, { - postcssOptions: { syntax: scssSyntax } -}); -const invalidValue = (prop, line, column) => { - return invalid(messages.invalidValue(prop), line, column); +const unknownAtrule = (atrule, line, column) => { + return invalid(messages.unknownAtrule(atrule), line, column); }; const invalidPrelude = (atrule, line, column) => { return invalid(messages.invalidPrelude(atrule), line, column); }; +const unknownProperty = (atrule, line, column) => { + return invalid(messages.unknownProperty(atrule), line, column); +}; +const invalidValue = (prop, line, column) => { + return invalid(messages.invalidValue(prop), line, column); +}; const invalid = (message, line, column) => { if (typeof line !== 'number' && typeof column !== 'number') { return message; @@ -36,7 +34,10 @@ css(null, function(tr) { tr.notOk('.foo { color: 1 }', invalidValue('color')); tr.notOk('.foo { color: #12345 }', invalidValue('color')); tr.notOk('.foo { color: &a }', messages.parseError('&a')); - tr.notOk('.foo { baz: 123 }', 'Unknown property `baz`'); + tr.notOk('.foo { baz: 123 }', unknownProperty('baz')); + tr.notOk('@plugin "my-plugin"', unknownAtrule('plugin')); + tr.notOk('@test;', unknownAtrule('test')); + tr.notOk('.foo { color: @foo }', messages.parseError('@foo')); }); // loc @@ -47,7 +48,7 @@ css(null, function(tr) { // atrules css(null, function(tr) { - tr.notOk(' @unknown {}', invalid('Unknown at-rule `@unknown`', 1, 3)); + tr.notOk(' @unknown {}', unknownAtrule('unknown', 1, 3)); tr.notOk(' @media ??? {}', invalidPrelude('media', 1, 10)); }); css({ ignoreAtrules: ['unknown', 'import'] }, function(tr) { @@ -60,73 +61,12 @@ css({ atrules: false }, function(tr) { tr.ok('@media ??? {}'); }); -// ignore values with less extenstions -less(null, function(tr) { - // variables - tr.ok('.foo { color: @var }'); - tr.ok('.foo { color: @@var }'); - tr.ok('.foo { color: @ }', messages.parseError('@')); - tr.ok('.foo { color: @123 }', messages.parseError('@123')); - tr.ok('.foo { color: @@@var }', messages.parseError('@@@var')); - - // escaping - tr.ok('.foo { color: ~"test" }'); - tr.ok('.foo { color: ~\'test\' }'); - tr.notOk('.foo { color: ~ }', messages.parseError('~')); - tr.notOk('.foo { color: ~123 }', messages.parseError('~123')); - - // interpolation - tr.ok('.foo { @{property}: 1 }'); - tr.ok('.foo { test-@{property}: 1 }'); - tr.ok('.foo { @{property}-test: 1 }'); - - // standalone var declarations - tr.ok('@foo: 2'); - - // namespaces - tr.ok('.foo { color: #outer > .inner(); }'); // deprecated - tr.ok('.foo { color: #outer> .inner(); }'); // deprecated - tr.ok('.foo { color: #outer >.inner(); }'); // deprecated - tr.ok('.foo { color: #outer>.inner(); }'); // deprecated - tr.ok('.foo { color: #outer .inner(); }'); // deprecated - tr.ok('.foo { color: #outer.inner(); }'); // preferred - tr.ok('.foo { color: #outer.inner(1 + 2); }'); // preferred -}); - -// ignore values with sass extenstions -sass(null, function(tr) { - // variables - tr.ok('.foo { color: $red }'); - tr.ok('.foo { color: $ }', messages.parseError('$')); - tr.ok('.foo { color: $123 }', messages.parseError('$123')); - tr.ok('.foo { color: $$123 }', messages.parseError('$$123')); - - // modulo operator - tr.ok('.foo { color: 3 % 6 }'); - - // interpolation - tr.ok('.foo { color: #{$var} }'); - tr.ok('.foo { color: #{1 + 2} }'); - tr.ok('.foo { max-height: calc(100vh - #{$navbar-height}); }'); - tr.ok('.foo { #{$property}: 1 }'); - tr.ok('.foo { test-#{$property}: 1 }'); - tr.ok('.foo { #{$property}-test: 1 }'); - - // standalone var declarations - tr.ok('$foo: 1'); - - // namespace - tr.ok('.foo { color: fonts.$font-family-text; }'); - tr.ok('.foo { color: fonts.foo(); }'); - tr.ok('.foo { color: fonts.foo(1 + 2); }'); -}); - // should ignore properties from `ignore` list css({ ignoreProperties: ['foo', 'bar'] }, function(tr) { tr.ok('.foo { foo: 1 }'); tr.ok('.foo { bar: 1 }'); tr.ok('.foo { BAR: 1 }'); - tr.notOk('.foo { baz: 1 }', 'Unknown property `baz`'); + tr.notOk('.foo { baz: 1 }', unknownProperty('baz')); }); // should ignore by ignoreValue pattern @@ -137,8 +77,8 @@ css({ ignoreValue: '^patternToIgnore$|=', ignoreProperties: ['bar'] }, function( tr.ok('.foo { bar: notMatchingPattern }'); tr.ok('.foo { color: alpha(opacity=1) }'); tr.notOk('.foo { color: notMatchingPattern }', invalidValue('color')); - tr.notOk('.foo { foo: patternToIgnore }', invalid('Unknown property `foo`', 1, 8)); - tr.notOk('.foo { foo: notMatchingPattern }', 'Unknown property `foo`'); + tr.notOk('.foo { foo: patternToIgnore }', unknownProperty('foo', 1, 8)); + tr.notOk('.foo { foo: notMatchingPattern }', unknownProperty('foo')); }); // extend dictionary @@ -159,7 +99,7 @@ css({ tr.ok('.foo { foo: my-fn(10%) }'); tr.ok('.foo { foo: my-fn(0) }'); tr.ok('.bar { bar: my-fn(10px) }'); - tr.notOk('.baz { baz: my-fn(10px) }', 'Unknown property `baz`'); + tr.notOk('.baz { baz: my-fn(10px) }', unknownProperty('baz')); tr.ok('.qux { qux: 10px }'); tr.notOk('.foo { color: my-fn(10px) }', invalidValue('color')); tr.ok('.foo { color: darken(white, 5%) }'); @@ -195,14 +135,26 @@ css({ tr.ok('@font-face { font-display: ignore-this }'); tr.ok('@font-face { ignore-descriptor: foo }'); tr.notOk('@font-face { font-display: ignore-that }', invalidValue('font-display', 1, 28)); - tr.notOk('.foo { font-display: swap }', invalid('Unknown property `font-display`', 1, 8)); - tr.notOk(' @not-import url("foo.css");', invalid('Unknown at-rule `@not-import`', 1, 3)); - // tr.notOk(' @-unknown-import url("foo.css");', invalid('Unknown at-rule `@-unknown-import`', 1, 3)); - tr.notOk(' @import { color: red }', invalid('At-rule `@import` should contain a prelude', 1, 11)); + tr.notOk('.foo { font-display: swap }', invalid(`Unknown property \`font-display\` (${ruleName})`, 1, 8)); + tr.notOk(' @not-import url("foo.css");', unknownAtrule('not-import', 1, 3)); + // tr.notOk(' @-unknown-import url("foo.css");', unknownAtrule("-")known-mport`', 1, 3)); + tr.notOk(' @import { color: red }', invalid(`At-rule \`@import\` should contain a prelude (${ruleName})`, 1, 11)); tr.notOk(' @import url("foo.css") .a', invalidPrelude('import', 1, 26)); - tr.notOk(' @font-face xx {}', invalid('At-rule `@font-face` should not contain a prelude', 1, 14)); + tr.notOk(' @font-face xx {}', invalid(`At-rule \`@font-face\` should not contain a prelude (${ruleName})`, 1, 14)); tr.notOk(' @font-face { font-display: foo }', invalidValue('font-display', 1, 30)); - tr.notOk(' @font-face { font-displa: block }', invalid('Unknown at-rule descriptor `font-displa`', 1, 16)); - tr.notOk(' @foo { color: ref }', [invalid('Unknown at-rule `@foo`', 1, 3)]); + tr.notOk(' @font-face { font-displa: block }', invalid(`Unknown at-rule descriptor \`font-displa\` (${ruleName})`, 1, 16)); + tr.notOk(' @foo { color: ref }', [unknownAtrule('foo', 1, 3)]); tr.notOk(' @media zzz zzz { color: ref }', [invalidPrelude('media', 1, 14)]); }); + +// options varnings +css({ + ignore: ['ignore-descriptor'] +}, function(tr) { + tr.notOk('a {}', `Invalid value "ignore-descriptor" for option "ignore" of rule "${ruleName}"`); +}); +css({ + syntaxExtensions: ['sass', 'xxx'] +}, function(tr) { + tr.notOk('a {}', `Invalid value "xxx" for option "syntaxExtensions" of rule "${ruleName}"`); +}); diff --git a/test/preprocessors.js b/test/preprocessors.js new file mode 100644 index 0000000..a4a4f93 --- /dev/null +++ b/test/preprocessors.js @@ -0,0 +1,82 @@ +import validator from 'stylelint-csstree-validator'; +import lessSyntax from 'postcss-less'; +import scssSyntax from 'postcss-scss'; +import ruleTester from './utils/tester.js'; + +const { rule, ruleName, messages } = validator; +const less = ruleTester(rule, ruleName, { + postcssOptions: { syntax: lessSyntax } +}); +const sass = ruleTester(rule, ruleName, { + postcssOptions: { syntax: scssSyntax } +}); + +const lessTests = function(tr) { + // variables + tr.ok('.foo { color: @var }'); + tr.ok('.foo { color: @@var }'); + tr.ok('.foo { color: @ }', messages.parseError('@')); + tr.ok('.foo { color: @123 }', messages.parseError('@123')); + tr.ok('.foo { color: @@@var }', messages.parseError('@@@var')); + + // escaping + tr.ok('.foo { color: ~"test" }'); + tr.ok('.foo { color: ~\'test\' }'); + tr.notOk('.foo { color: ~ }', messages.parseError('~')); + tr.notOk('.foo { color: ~123 }', messages.parseError('~123')); + + // interpolation + tr.ok('.foo { @{property}: 1 }'); + tr.ok('.foo { test-@{property}: 1 }'); + tr.ok('.foo { @{property}-test: 1 }'); + + // standalone var declarations + tr.ok('@foo: 2'); + + // namespaces + tr.ok('.foo { color: #outer > .inner(); }'); // deprecated + tr.ok('.foo { color: #outer> .inner(); }'); // deprecated + tr.ok('.foo { color: #outer >.inner(); }'); // deprecated + tr.ok('.foo { color: #outer>.inner(); }'); // deprecated + tr.ok('.foo { color: #outer .inner(); }'); // deprecated + tr.ok('.foo { color: #outer.inner(); }'); // preferred + tr.ok('.foo { color: #outer.inner(1 + 2); }'); // preferred + + // custom at-rules + // tr.ok('@plugin "my-plugin";'); +}; + +const sassTests = function(tr) { + // variables + tr.ok('.foo { color: $red }'); + tr.ok('.foo { color: $ }', messages.parseError('$')); + tr.ok('.foo { color: $123 }', messages.parseError('$123')); + tr.ok('.foo { color: $$123 }', messages.parseError('$$123')); + + // modulo operator + tr.ok('.foo { color: 3 % 6 }'); + + // interpolation + tr.ok('.foo { color: #{$var} }'); + tr.ok('.foo { color: #{1 + 2} }'); + tr.ok('.foo { max-height: calc(100vh - #{$navbar-height}); }'); + tr.ok('.foo { #{$property}: 1 }'); + tr.ok('.foo { test-#{$property}: 1 }'); + tr.ok('.foo { #{$property}-test: 1 }'); + + // standalone var declarations + tr.ok('$foo: 1'); + + // namespace + tr.ok('.foo { color: fonts.$font-family-text; }'); + tr.ok('.foo { color: fonts.foo(); }'); + tr.ok('.foo { color: fonts.foo(1 + 2); }'); +}; + +// less extenstions +less({ syntaxExtensions: ['less'] }, lessTests); +less({ syntaxExtensions: ['less', 'sass'] }, lessTests); + +// sass extenstions +sass({ syntaxExtensions: ['sass'] }, sassTests); +sass({ syntaxExtensions: ['sass', 'less'] }, sassTests);