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 media-feature-name-no-unknown false positive for not and or #6838

5 changes: 5 additions & 0 deletions .changeset/hungry-bugs-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stylelint": patch
---

Fixed: `media-feature-name-no-unknown` false positives for `not` and `or`
121 changes: 121 additions & 0 deletions lib/rules/media-feature-name-no-unknown/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ testRule({
{
code: '@media (forced-colors: active) { }',
},
{
code: '@media not (width) { }',
},
{
code: '@media (not (width)) { }',
},
{
code: '@media (width) or (height) { }',
},
{
code: '@media ((width) or (height)) { }',
},
{
code: '@media () { }',
},
],

reject: [
Expand Down Expand Up @@ -134,12 +149,36 @@ testRule({
{
code: '@media (min-width: @tablet) { }',
},
{
code: '@media (@feature: @tablet) { }',
},
{
code: '@media (@feature: 100px) { }',
},
{
code: '@media (@feature = @tablet) { }',
},
{
code: '@media (@feature = 100px) { }',
},
{
code: '@media (@feature < @tablet) { }',
},
{
code: '@media (@mobile < @feature < @tablet) { }',
},
{
code: '@media (10px < @feature < 100px) { }',
},
{
code: '@media @smartphones and (orientation: landscape) { }',
},
{
code: '@media @smartphones { }',
},
{
code: '@media (@smartphones) { }',
},
],

reject: [
Expand All @@ -151,6 +190,38 @@ testRule({
endLine: 1,
endColumn: 16,
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
},
{
code: '@media (unknown < @tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media (unknown = @tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media (@tablet = unknown) { }',
message: messages.rejected('unknown'),
line: 1,
column: 19,
endLine: 1,
endColumn: 26,
},
{
code: '@media (@mobile <= unknown < @tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 20,
endLine: 1,
endColumn: 27,
},
],
});

Expand Down Expand Up @@ -184,6 +255,24 @@ testRule({
{
code: '@media #{$feature-name} { }',
},
{
code: '@media ($feature: 100px) { }',
},
{
code: '@media ($feature = $tablet) { }',
},
{
code: '@media ($feature = 100px) { }',
},
{
code: '@media ($feature < $tablet) { }',
},
{
code: '@media ($mobile < $feature < $tablet) { }',
},
{
code: '@media (10px < $feature < 100px) { }',
},
],

reject: [
Expand All @@ -195,6 +284,38 @@ testRule({
endLine: 1,
endColumn: 16,
},
{
code: '@media (unknown < $tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media (unknown = $tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media ($tablet = unknown) { }',
message: messages.rejected('unknown'),
line: 1,
column: 19,
endLine: 1,
endColumn: 26,
},
{
code: '@media ($mobile <= unknown < $tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 20,
endLine: 1,
endColumn: 27,
},
],
});

Expand Down
139 changes: 111 additions & 28 deletions lib/rules/media-feature-name-no-unknown/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
'use strict';

const {
isMediaFeature,
isMediaQueryInvalid,
isGeneralEnclosed,
} = require('@csstools/media-query-list-parser');

const atRuleParamIndex = require('../../utils/atRuleParamIndex');
const isCustomMediaQuery = require('../../utils/isCustomMediaQuery');
const isRangeContextMediaFeature = require('../../utils/isRangeContextMediaFeature');
const isStandardSyntaxMediaFeatureName = require('../../utils/isStandardSyntaxMediaFeatureName');
const { mediaFeatureNames } = require('../../reference/mediaFeatures');
const mediaParser = require('postcss-media-query-parser').default;
const optionsMatches = require('../../utils/optionsMatches');
const rangeContextNodeParser = require('../rangeContextNodeParser');
const parseMediaQuery = require('../../utils/parseMediaQuery');
const report = require('../../utils/report');
const ruleMessages = require('../../utils/ruleMessages');
const validateOptions = require('../../utils/validateOptions');
const vendor = require('../../utils/vendor');
const { isRegExp, isString } = require('../../utils/validateTypes');
const { TokenType, isToken } = require('@csstools/css-tokenizer');
const { isTokenNode } = require('@csstools/css-parser-algorithms');
romainmenke marked this conversation as resolved.
Show resolved Hide resolved

const ruleName = 'media-feature-name-no-unknown';

Expand All @@ -24,6 +30,8 @@ const meta = {
url: 'https://stylelint.io/user-guide/rules/media-feature-name-no-unknown',
};

const rangeFeatureOperator = /[<>=]/;

/** @type {import('stylelint').Rule} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
Expand All @@ -45,52 +53,127 @@ const rule = (primary, secondaryOptions) => {
}

root.walkAtRules(/^media$/i, (atRule) => {
mediaParser(atRule.params).walk(/^media-feature$/i, (mediaFeatureNode) => {
const parent = mediaFeatureNode.parent;
const mediaFeatureRangeContext = isRangeContextMediaFeature(parent.value);

let value;
let sourceIndex;

if (mediaFeatureRangeContext) {
const parsedRangeContext = rangeContextNodeParser(mediaFeatureNode);

value = parsedRangeContext.name.value;
sourceIndex = parsedRangeContext.name.sourceIndex;
} else {
value = mediaFeatureNode.value;
sourceIndex = mediaFeatureNode.sourceIndex;
}

if (!isStandardSyntaxMediaFeatureName(value) || isCustomMediaQuery(value)) {
/**
* @param {string} featureName
* @param {number} startIndex
* @param {number} endIndex
*/
const validateFeatureName = (featureName, startIndex, endIndex) => {
romainmenke marked this conversation as resolved.
Show resolved Hide resolved
if (!isStandardSyntaxMediaFeatureName(featureName) || isCustomMediaQuery(featureName)) {
return;
}

if (optionsMatches(secondaryOptions, 'ignoreMediaFeatureNames', value)) {
if (optionsMatches(secondaryOptions, 'ignoreMediaFeatureNames', featureName)) {
return;
}

if (vendor.prefix(value) || mediaFeatureNames.has(value.toLowerCase())) {
if (vendor.prefix(featureName) || mediaFeatureNames.has(featureName.toLowerCase())) {
return;
}

const index = atRuleParamIndex(atRule) + sourceIndex;
const endIndex = index + value.length;
const atRuleIndex = atRuleParamIndex(atRule);

report({
index,
endIndex,
message: messages.rejected,
messageArgs: [value],
messageArgs: [featureName],
node: atRule,
index: atRuleIndex + startIndex,
endIndex: atRuleIndex + endIndex + 1,
ruleName,
result,
});
};

const mediaQueryList = parseMediaQuery(atRule);

mediaQueryList.forEach((mediaQuery) => {
if (isMediaQueryInvalid(mediaQuery)) return;

mediaQuery.walk(({ node }) => {
if (isMediaFeature(node)) {
const [, , startIndex, endIndex] = node.getNameToken();

validateFeatureName(node.getName(), startIndex, endIndex);

return;
}

if (isGeneralEnclosed(node)) {
const relevantTokens = topLevelTokenNodes(node);

if (!relevantTokens) {
return;
}

relevantTokens.forEach((token, i) => {
if (token[0] !== TokenType.Ident) {
return;
}

const nextToken = relevantTokens[i + 1];
const prevToken = relevantTokens[i - 1];

if (
// Media Feature
(!prevToken && nextToken && nextToken[0] === TokenType.Colon) ||
// Range Feature
(nextToken &&
nextToken[0] === TokenType.Delim &&
rangeFeatureOperator.test(nextToken[4].value)) ||
// Range Feature
(prevToken &&
prevToken[0] === TokenType.Delim &&
rangeFeatureOperator.test(prevToken[4].value))
) {
const [, , startIndex, endIndex, { value: featureName }] = token;

validateFeatureName(featureName, startIndex, endIndex);
}
});
}
});
});
});
};
};

/** @param {import('@csstools/media-query-list-parser').GeneralEnclosed} node */
function topLevelTokenNodes(node) {
const components = node.value.value;

if (isToken(components) || components.length === 0 || isToken(components[0])) {
return false;
}

/** @type {Array<import('@csstools/css-tokenizer').CSSToken>} */
const relevantTokens = [];

// To consume the next token if it is a scss variable
let lastWasDollarSign = false;

components.forEach((component) => {
// Only preserve top level tokens (idents, delims, ...)
// Discard all blocks, functions, ...
if (component && isTokenNode(component)) {
if (component.value[0] === TokenType.Delim && component.value[4].value === '$') {
lastWasDollarSign = true;

return;
}

if (lastWasDollarSign) {
lastWasDollarSign = false;

return;
}

relevantTokens.push(component.value);
}
});

return relevantTokens;
}

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
Expand Down