Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix unit-no-unknown false positives for the second and subsequent image-set() with x descriptor #6879

16 changes: 14 additions & 2 deletions lib/rules/unit-no-unknown/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ testRule({
code: "a { background-image: -webkit-image-set('img-1x.jpg' 1x, 'img-2x.jpg' 2x) }",
description: 'ignore `x` unit in vendor-prefixed image-set',
},
{
code: "a { background-image: url('first.png'), image-set(url('second.png') 1x), image-set(url('third.png') 1x) }",
description: 'ignore `x` unit in the second and subsequent image-set',
},
{
code: "a { background-image: url('first.png'), -webkit-image-set(url('second.png') 1x), -webkit-image-set(url('third.png') 1x) }",
description: 'ignore `x` unit in the second and subsequent vendor-prefixed image-set',
},
{
code: "a { background-image: image-set(url('first.png') calc(1x * 1), url('second.png') calc(1x + 0.5x)); }",
description: 'ignore `x` unit in image-set with calc',
},
{
code: '@media (resolution: 2x) {}',
description: 'ignore `x` unit in @media with `resolution`',
Expand Down Expand Up @@ -443,9 +455,9 @@ testRule({
message: messages.rejected('x'),
description: '`x` rejected with inappropriate property',
line: 1,
column: 47,
column: 60,
endLine: 1,
endColumn: 48,
endColumn: 61,
},
{
code: "a { background: image-set('img1x.png' 1x, 'img2x.png' 2x) left 20x / 15% 60% repeat-x; }",
Expand Down
221 changes: 129 additions & 92 deletions lib/rules/unit-no-unknown/index.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
'use strict';

const { tokenize, TokenType } = require('@csstools/css-tokenizer');
const {
parseCommaSeparatedListOfComponentValues,
isFunctionNode,
isSimpleBlockNode,
isTokenNode,
} = require('@csstools/css-parser-algorithms');
const { isMediaFeature, parseFromTokens } = require('@csstools/media-query-list-parser');

const atRuleParamIndex = require('../../utils/atRuleParamIndex');
const declarationValueIndex = require('../../utils/declarationValueIndex');
const getDimension = require('../../utils/getDimension');
const getAtRuleParams = require('../../utils/getAtRuleParams');
const getDeclarationValue = require('../../utils/getDeclarationValue');
const isStandardSyntaxAtRule = require('../../utils/isStandardSyntaxAtRule');
const isStandardSyntaxDeclaration = require('../../utils/isStandardSyntaxDeclaration');
const { units } = require('../../reference/units');
const mediaParser = require('postcss-media-query-parser').default;
const isStandardSyntaxValue = require('../../utils/isStandardSyntaxValue');
const optionsMatches = require('../../utils/optionsMatches');
const report = require('../../utils/report');
const ruleMessages = require('../../utils/ruleMessages');
const validateOptions = require('../../utils/validateOptions');
const valueParser = require('postcss-value-parser');
const vendor = require('../../utils/vendor');
const { isRegExp, isString, assert } = require('../../utils/validateTypes');
const { isAtRule } = require('../../utils/typeGuards');
const isStandardSyntaxValue = require('../../utils/isStandardSyntaxValue');
const { isRegExp, isString } = require('../../utils/validateTypes');
const { units } = require('../../reference/units');

const ruleName = 'unit-no-unknown';

Expand All @@ -27,6 +34,9 @@ const meta = {
url: 'https://stylelint.io/user-guide/rules/unit-no-unknown',
};

const HAS_DIMENSION_LIKE_VALUES = /\d[\w-]/;
const RESOLUTION_MEDIA_FEATURE_NAME = /^(?:min-|max-)?resolution$/i;

/** @type {import('stylelint').Rule} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
Expand All @@ -48,122 +58,149 @@ const rule = (primary, secondaryOptions) => {
return;
}

/**
* @param {string} value
*/
const tokenizeIfValueMightContainUnknownUnits = (value) => {
if (!HAS_DIMENSION_LIKE_VALUES.test(value)) return;

const tokens = tokenize({ css: value });
const hasUnknownUnits = tokens.some((token) => {
return token[0] === TokenType.Dimension && !units.has(token[4].unit.toLowerCase());
});

if (!hasUnknownUnits) return;

return tokens;
};

/**
* @template {import('postcss').AtRule | import('postcss').Declaration} T
* @param {T} node
* @param {string} value
* @param {(node: T) => number} getIndex
* @returns {void}
* @param {import('@csstools/css-parser-algorithms').FunctionNode | import('@csstools/css-parser-algorithms').TokenNode} componentValue
* @param {{ ignored: boolean, allowX: boolean }} state
*/
function check(node, value, getIndex) {
// make sure multiplication operations (*) are divided - not handled
// by postcss-value-parser
value = value.replace(/\*/g, ',');
const parsedValue = valueParser(value);

parsedValue.walk((valueNode) => {
// Ignore wrong units within `url` function
// and within functions listed in the `ignoreFunctions` option
const check = (node, getIndex, componentValue, state) => {
if (isFunctionNode(componentValue)) {
const name = componentValue.getName();

if (
valueNode.type === 'function' &&
(valueNode.value.toLowerCase() === 'url' ||
optionsMatches(secondaryOptions, 'ignoreFunctions', valueNode.value))
name.toLowerCase() === 'url' ||
optionsMatches(secondaryOptions, 'ignoreFunctions', name)
) {
return false;
}
state.ignored = true;

const { number, unit } = getDimension(valueNode);

if (!number || !unit) {
return;
}

if (optionsMatches(secondaryOptions, 'ignoreUnits', unit)) {
return;
}
if (
vendor.unprefixed(name.toLowerCase()) === 'image-set' ||
optionsMatches(secondaryOptions, 'ignoreFunctions', name)
) {
state.allowX = true;

if (units.has(unit.toLowerCase()) && unit.toLowerCase() !== 'x') {
return;
}

if (unit.toLowerCase() === 'x') {
if (
isAtRule(node) &&
node.name === 'media' &&
node.params.toLowerCase().includes('resolution')
) {
let ignoreUnit = false;

mediaParser(node.params).walk((mediaNode, _i, mediaNodes) => {
const lastMediaNode = mediaNodes[mediaNodes.length - 1];

if (
mediaNode.value.toLowerCase().includes('resolution') &&
lastMediaNode &&
lastMediaNode.sourceIndex === valueNode.sourceIndex
) {
ignoreUnit = true;

return false;
}
});

if (ignoreUnit) {
return;
}
}
return;
}

if (node.type === 'decl') {
if (node.prop.toLowerCase() === 'image-resolution') {
return;
}
const [tokenType, , , endIndex, tokenValue] = componentValue.value;

if (/^(?:-webkit-)?image-set[\s(]/i.test(value)) {
const imageSet = parsedValue.nodes.find(
(n) => vendor.unprefixed(n.value) === 'image-set',
);
if (tokenType !== TokenType.Dimension) return;

assert(imageSet);
assert('nodes' in imageSet);
const imageSetLastNode = imageSet.nodes[imageSet.nodes.length - 1];
if (optionsMatches(secondaryOptions, 'ignoreUnits', tokenValue.unit)) return;

assert(imageSetLastNode);
const imageSetValueLastIndex = imageSetLastNode.sourceIndex;
const unit = tokenValue.unit.toLowerCase();

if (imageSetValueLastIndex >= valueNode.sourceIndex) {
return;
}
}
}
}
if (unit === 'x' && state.allowX) return;

const index = getIndex(node);
if (units.has(unit) && unit !== 'x') return;

report({
index: index + valueNode.sourceIndex + number.length,
endIndex: index + valueNode.sourceEndIndex,
message: messages.rejected,
messageArgs: [unit],
node,
result,
ruleName,
});
const startIndex = getIndex(node) + (endIndex + 1) - unit.length;

report({
message: messages.rejected,
messageArgs: [tokenValue.unit],
node,
index: startIndex,
endIndex: startIndex + unit.length,
result,
ruleName,
});
}
};

root.walkAtRules(/^media$/i, (atRule) => {
if (!isStandardSyntaxAtRule(atRule)) {
return;
}
if (!isStandardSyntaxAtRule(atRule)) return;

const params = getAtRuleParams(atRule);

const tokens = tokenizeIfValueMightContainUnknownUnits(params);

if (!tokens) return;

check(atRule, atRule.params, atRuleParamIndex);
parseFromTokens(tokens).forEach((mediaQuery) => {
const state = {
ignored: false,
allowX: false,
};

mediaQuery.walk((entry) => {
romainmenke marked this conversation as resolved.
Show resolved Hide resolved
if (entry.state?.ignored) return;

if (isMediaFeature(entry.node)) {
const name = entry.node.getName();

if (entry.state && RESOLUTION_MEDIA_FEATURE_NAME.test(name)) {
entry.state.allowX = true;
}
}

if (isFunctionNode(entry.node) || isTokenNode(entry.node)) {
romainmenke marked this conversation as resolved.
Show resolved Hide resolved
check(atRule, atRuleParamIndex, entry.node, entry.state ?? state);
}
}, state);
});
});

root.walkDecls((decl) => {
if (!isStandardSyntaxDeclaration(decl)) return;

if (!isStandardSyntaxValue(decl.value)) return;
const value = getDeclarationValue(decl);

if (!isStandardSyntaxValue(value)) return;

const tokens = tokenizeIfValueMightContainUnknownUnits(value);

if (!tokens) return;

const isImageResolutionProp = decl.prop.toLowerCase() === 'image-resolution';

parseCommaSeparatedListOfComponentValues(tokens)
.flatMap((x) => x)
.forEach((componentValue) => {
const state = {
ignored: false,
allowX: isImageResolutionProp,
};

check(decl, decl.value, declarationValueIndex);
if (isFunctionNode(componentValue) || isTokenNode(componentValue)) {
check(decl, declarationValueIndex, componentValue, state);
}

if (!isFunctionNode(componentValue) && !isSimpleBlockNode(componentValue)) {
return;
}

componentValue.walk((entry) => {
if (entry.state?.ignored) return;

if (isFunctionNode(entry.node) || isTokenNode(entry.node)) {
check(decl, declarationValueIndex, entry.node, entry.state ?? state);
}
}, state);
});
});
};
};
Expand Down