UNPKG

stylelint-scss

Version:

A collection of SCSS-specific rules for Stylelint

327 lines (287 loc) 9.43 kB
import mediaQueryParserModule from "postcss-media-query-parser"; import stylelint from "stylelint"; import atRuleParamIndex from "../../utils/atRuleParamIndex.js"; import declarationValueIndex from "../../utils/declarationValueIndex.js"; import eachRoot from "../../utils/eachRoot.js"; import findCommentsInRaws from "../../utils/findCommentsInRaws.js"; import findOperators from "../../utils/sassValueParser/index.js"; import isWhitespace from "../../utils/isWhitespace.js"; import namespace from "../../utils/namespace.js"; import ruleUrl from "../../utils/ruleUrl.js"; const { utils } = stylelint; const mediaQueryParser = mediaQueryParserModule.default; const ruleName = namespace("operator-no-unspaced"); const messages = utils.ruleMessages(ruleName, { expectedAfter: operator => `Expected single space after "${operator}"`, expectedBefore: operator => `Expected single space before "${operator}"` }); const meta = { url: ruleUrl(ruleName) }; /** * The actual check for are there (un)necessary whitespaces */ function checkSpaces({ string, globalIndex, startIndex, endIndex, node, result }) { const symbol = string.substring(startIndex, endIndex + 1); const beforeOk = (string[startIndex - 1] === " " && !isWhitespace(string[startIndex - 2])) || newlineBefore(string, startIndex - 1); if (!beforeOk) { const index = globalIndex + startIndex; utils.report({ ruleName, result, node, message: messages.expectedBefore(symbol), index, endIndex: index + symbol.length }); } const afterOk = (string[endIndex + 1] === " " && !isWhitespace(string[endIndex + 2])) || string[endIndex + 1] === "\n" || string.substr(endIndex + 1, 2) === "\r\n"; if (!afterOk) { const index = globalIndex + startIndex; utils.report({ ruleName, result, node, message: messages.expectedAfter(symbol), index, endIndex: index + symbol.length }); } } function newlineBefore(str, startIndex) { let index = startIndex; while (index && isWhitespace(str[index])) { if (str[index] === "\n") return true; index--; } return false; } function rule(expectation) { return (root, result) => { const validOptions = utils.validateOptions(result, ruleName, { actual: expectation }); if (!validOptions) { return; } eachRoot(root, checkRoot); function checkRoot(root) { const rootString = root.source.input.css; if (rootString.trim() === "") { return; } calculationOperatorSpaceChecker({ root, result, checker: checkSpaces }); } }; } rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; /** * The core rule logic function. This one can be imported by some other rules * that work with Sass operators * * @param {Object} args -- Named arguments object * @param {PostCSS Root} args.root * @param {PostCSS Result} args.result * @param {function} args.checker -- the function that is run against all the * operators found in the input. Takes these arguments: * {Object} cbArgs -- Named arguments object * {string} cbArgs.string -- the input string (suspected operation) * {number} cbArgs.globalIndex -- the string's index in a global input * {number} cbArgs.startIndex -- the start index of a symbol to inspect * {number} cbArgs.endIndex -- the end index of a symbol to inspect * (two indexes needed to allow for `==`, `!=`, etc.) * {PostCSS Node} cbArgs.node -- for stylelint.utils.report * {PostCSS Result} cbArgs.result -- for stylelint.utils.report */ export function calculationOperatorSpaceChecker({ root, result, checker }) { /** * Takes a string, finds all occurrences of Sass interpolation in it, then * finds all operators inside that interpolation * * @return {array} An array of objects { string, operators } - effectively, * a list of operators for each Sass interpolation occurrence */ function findInterpolation(string, startIndex) { const interpolationRegex = /#{(.*?)}/g; const results = []; // Searching for interpolation let match = interpolationRegex.exec(string); startIndex = !isNaN(startIndex) ? Number(startIndex) : 0; while (match !== null) { results.push({ source: match[0], operators: findOperators({ string: match[0], globalIndex: match.index + startIndex }) }); match = interpolationRegex.exec(string); } return results; } const dataURIRegex = /url\(\s*['"]?data:.+['"]?\s*\)/; root.walk(item => { if (item.prop === "unicode-range") { return; } let results = []; // Check a value (`10px` in `width: 10px;`) if (item.value !== undefined) { if (dataURIRegex.test(item.value)) { return results; } results.push({ source: item.value, operators: findOperators({ string: item.value, globalIndex: declarationValueIndex(item), // For Sass variable values some special rules apply isAfterColon: item.prop[0] === "$" }) }); } // Property name if (item.prop !== undefined) { results = results.concat(findInterpolation(item.prop)); } // Selector if (item.selector !== undefined) { results = results.concat(findInterpolation(item.selector)); } if (item.type === "atrule") { // @forward, @use, @at-root, and @scope if ( item.name === "forward" || item.name === "use" || item.name === "at-root" || item.name === "scope" ) { return; } // Tailwind-specific at-rules (Directives) // https://tailwindcss.com/docs/functions-and-directives#directives if ( item.name === "apply" || item.name === "custom-variant" || item.name === "reference" || item.name === "source" || item.name === "theme" || item.name === "utility" || item.name === "variant" ) { return; } // Media queries if (item.name === "media" || item.name === "import") { mediaQueryParser(item.params).walk(node => { const type = node.type; if (["keyword", "media-type", "media-feature"].includes(type)) { results = results.concat( findInterpolation( node.value, atRuleParamIndex(item) + node.sourceIndex ) ); } else if (type === "value") { results.push({ source: node.value, operators: findOperators({ string: node.value, globalIndex: atRuleParamIndex(item) + node.sourceIndex, isAfterColon: true }) }); } else if (type === "url") { const isQuoted = node.value[0] === '"' || node.value[0] === "'"; const containsWhitespace = node.value.search(/\s/) > -1; if (isQuoted || containsWhitespace) { // The argument to the url function is only parsed as SassScript if it is a quoted // string, or a _valid_ unquoted URL [1]. // // [1] https://sass-lang.com/documentation/syntax/special-functions#url results.push({ source: node.value, operators: findOperators({ string: node.value, globalIndex: atRuleParamIndex(item) + node.sourceIndex, isAfterColon: true }) }); } } }); } else { // Function and mixin definitions and other rules results.push({ source: item.params, operators: findOperators({ string: item.params, globalIndex: atRuleParamIndex(item), isAfterColon: true }) }); } } // All the strings have been parsed, now run whitespace checking results.forEach(el => { // Only if there are operators within a string if (el.operators && el.operators.length > 0) { el.operators.forEach(operator => { checker({ string: el.source, globalIndex: operator.globalIndex, startIndex: operator.startIndex, endIndex: operator.endIndex, node: item, result }); }); } }); }); // Checking interpolation inside comments // We have to give up on PostCSS here because it skips some inline comments findCommentsInRaws(root.source.input.css).forEach(comment => { const startIndex = comment.source.start + comment.raws.startToken.length + comment.raws.left.length; if (comment.type !== "css") { return; } findInterpolation(comment.text).forEach(el => { // Only if there are operators within a string if (el.operators && el.operators.length > 0) { el.operators.forEach(operator => { checker({ string: el.source, globalIndex: operator.globalIndex + startIndex, startIndex: operator.startIndex, endIndex: operator.endIndex, node: root, result }); }); } }); }); } export default rule;