diff --git a/.changeset/real-dingos-boil.md b/.changeset/real-dingos-boil.md new file mode 100644 index 0000000000..3100ebff60 --- /dev/null +++ b/.changeset/real-dingos-boil.md @@ -0,0 +1,5 @@ +--- +"stylelint": minor +--- + +Added: `media-feature-name-value-no-unknown` diff --git a/docs/user-guide/rules.md b/docs/user-guide/rules.md index f28fb1ef9b..3ec75515ed 100644 --- a/docs/user-guide/rules.md +++ b/docs/user-guide/rules.md @@ -88,6 +88,7 @@ Disallow unknown things with these `no-unknown` rules. - [`declaration-property-value-no-unknown`](../../lib/rules/declaration-property-value-no-unknown/README.md): Disallow unknown values for properties within declarations. - [`function-no-unknown`](../../lib/rules/function-no-unknown/README.md): Disallow unknown functions (Ⓡ & Ⓢ). - [`media-feature-name-no-unknown`](../../lib/rules/media-feature-name-no-unknown/README.md): Disallow unknown media feature names (Ⓡ & Ⓢ). +- [`media-feature-name-value-no-unknown`](../../lib/rules/media-feature-name-value-no-unknown/README.md): Disallow unknown values for media features. - [`no-unknown-animations`](../../lib/rules/no-unknown-animations/README.md): Disallow unknown animations. - [`no-unknown-custom-properties`](../../lib/rules/no-unknown-custom-properties/README.md): Disallow unknown custom properties. - [`property-no-unknown`](../../lib/rules/property-no-unknown/README.md): Disallow unknown properties (Ⓡ & Ⓢ). diff --git a/lib/reference/mediaFeatures.js b/lib/reference/mediaFeatures.js index 44749aaa48..14f65f19bd 100644 --- a/lib/reference/mediaFeatures.js +++ b/lib/reference/mediaFeatures.js @@ -6,25 +6,27 @@ const deprecatedMediaFeatureNames = new Set([ 'device-aspect-ratio', 'device-height', 'device-width', - 'max-device-aspect-ratio', - 'max-device-height', - 'max-device-width', - 'min-device-aspect-ratio', - 'min-device-height', - 'min-device-width', ]); -const rangeTypeMediaFeatureNames = new Set([ +const rangeTypeMediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, [ 'aspect-ratio', - 'color-index', 'color', + 'color-index', 'height', + 'horizontal-viewport-segments', 'monochrome', 'resolution', + 'vertical-viewport-segments', 'width', ]); -const mediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, rangeTypeMediaFeatureNames, [ +const rangeTypeMediaFeatureNamesWithMinMaxPrefix = new Set( + [...rangeTypeMediaFeatureNames].flatMap((name) => { + return [`min-${name}`, `max-${name}`]; + }), +); + +const discreteTypeMediaFeatureNames = new Set([ 'any-hover', 'any-pointer', 'color-gamut', @@ -35,20 +37,6 @@ const mediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, rangeTypeMediaF 'hover', 'inverted-colors', 'light-level', - 'max-aspect-ratio', - 'max-color', - 'max-color-index', - 'max-height', - 'max-monochrome', - 'max-resolution', - 'max-width', - 'min-aspect-ratio', - 'min-color', - 'min-color-index', - 'min-height', - 'min-monochrome', - 'min-resolution', - 'min-width', 'orientation', 'overflow-block', 'overflow-inline', @@ -63,7 +51,61 @@ const mediaFeatureNames = uniteSets(deprecatedMediaFeatureNames, rangeTypeMediaF 'video-dynamic-range', ]); -module.exports = { +const mediaFeatureNames = uniteSets( + deprecatedMediaFeatureNames, rangeTypeMediaFeatureNames, + rangeTypeMediaFeatureNamesWithMinMaxPrefix, + discreteTypeMediaFeatureNames, +); + +const mediaFeatureNameAllowedValueKeywords = new Map([ + ['any-hover', new Set(['none', 'hover'])], + ['any-pointer', new Set(['none', 'coarse', 'fine'])], + ['color-gamut', new Set(['srgb', 'p3', 'rec2020'])], + ['display-mode', new Set(['fullscreen', 'standalone', 'minimal-ui', 'browser'])], + ['dynamic-range', new Set(['standard', 'high'])], + ['environment-blending', new Set(['opaque', 'additive', 'subtractive'])], + ['forced-colors', new Set(['none', 'active'])], + ['hover', new Set(['none', 'hover'])], + ['inverted-colors', new Set(['none', 'inverted'])], + ['nav-controls', new Set(['none', 'back'])], + ['orientation', new Set(['portrait', 'landscape'])], + ['overflow-block', new Set(['none', 'scroll', 'paged'])], + ['overflow-inline', new Set(['none', 'scroll'])], + ['pointer', new Set(['none', 'coarse', 'fine'])], + ['prefers-color-scheme', new Set(['light', 'dark'])], + ['prefers-contrast', new Set(['no-preference', 'less', 'more', 'custom'])], + ['prefers-reduced-data', new Set(['no-preference', 'reduce'])], + ['prefers-reduced-motion', new Set(['no-preference', 'reduce'])], + ['prefers-reduced-transparency', new Set(['no-preference', 'reduce'])], + ['resolution', new Set(['infinite'])], + ['scan', new Set(['interlace', 'progressive'])], + ['scripting', new Set(['none', 'initial-only', 'enabled'])], + ['update', new Set(['none', 'slow', 'fast'])], + ['video-color-gamut', new Set(['srgb', 'p3', 'rec2020'])], + ['video-dynamic-range', new Set(['standard', 'high'])], +]); + +const mediaFeatureNameAllowedValueTypes = new Map([ + ['aspect-ratio', new Set(['ratio'])], + ['color', new Set(['integer'])], + ['color-index', new Set(['integer'])], + ['device-aspect-ratio', new Set(['ratio'])], + ['device-height', new Set(['length'])], + ['device-width', new Set(['length'])], + ['grid', new Set(['mq-boolean'])], + ['height', new Set(['length'])], + ['horizontal-viewport-segments', new Set(['integer'])], + ['monochrome', new Set(['integer'])], + ['resolution', new Set(['resolution'])], + ['vertical-viewport-segments', new Set(['integer'])], + ['width', new Set(['length'])], +]); + +module.exports = { + mediaFeatureNameAllowedValueKeywords, + mediaFeatureNameAllowedValueTypes, mediaFeatureNames, + rangeTypeMediaFeatureNames, + rangeTypeMediaFeatureNamesWithMinMaxPrefix, }; diff --git a/lib/reference/units.js b/lib/reference/units.js index a2cf2a1aba..aaadd4ce17 100644 --- a/lib/reference/units.js +++ b/lib/reference/units.js @@ -62,7 +62,9 @@ const lengthUnits = new Set([ 'cqmax', ]); -const units = uniteSets(lengthUnits, [ +const resolutionUnits = new Set(['dpi', 'dpcm', 'dppx', 'x']); + +const units = uniteSets(lengthUnits, resolutionUnits, [ // Relative length units '%', // Time length units @@ -76,13 +78,10 @@ const units = uniteSets(lengthUnits, [ // Frequency 'Hz', 'kHz', - // Resolution - 'dpi', - 'dpcm', - 'dppx', ]); module.exports = { lengthUnits, + resolutionUnits, units, }; diff --git a/lib/rules/index.js b/lib/rules/index.js index 669a4a0544..68f5fd871e 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -200,6 +200,9 @@ const rules = { 'media-feature-name-value-allowed-list': importLazy(() => require('./media-feature-name-value-allowed-list'), )(), + 'media-feature-name-value-no-unknown': importLazy(() => + require('./media-feature-name-value-no-unknown'), + )(), 'media-feature-parentheses-space-inside': importLazy(() => require('./media-feature-parentheses-space-inside'), )(), diff --git a/lib/rules/media-feature-name-value-no-unknown/README.md b/lib/rules/media-feature-name-value-no-unknown/README.md new file mode 100644 index 0000000000..5e8ee5371d --- /dev/null +++ b/lib/rules/media-feature-name-value-no-unknown/README.md @@ -0,0 +1,57 @@ +# media-feature-name-value-no-unknown + +Disallow unknown values for media features. + + +```css +@media (color: red) {} +/** ↑ ↑ + * feature and value pairs like these */ +``` + +This rule considers values for media features defined within the CSS specifications to be known. + +This rule is only appropriate for CSS. You should not turn it on for CSS-like languages, such as Sass or Less, as they have their own syntaxes. + +This rule is experimental with some false negatives that we'll patch in minor releases. + +It sometimes overlaps with: + +- [`unit-no-unknown`](../unit-no-unknown/README.md) + +If duplicate problems are flagged, you can turn off the corresponding rule. + +The [`message` secondary option](../../../docs/user-guide/configure.md#message) can accept the arguments of this rule. + +## Options + +### `true` + +The following patterns are considered problems: + + +```css +@media (color: red) { top: 1px; } +``` + + +```css +@media (width: 10) { top: 1px; } +``` + + +```css +@media (width: auto) { top: 1px; } +``` + +The following patterns are _not_ considered problems: + + +```css +@media (color: 8) { top: 1px; } +``` + + +```css +@media (width: 10px) { top: 1px; } +``` diff --git a/lib/rules/media-feature-name-value-no-unknown/__tests__/index.js b/lib/rules/media-feature-name-value-no-unknown/__tests__/index.js new file mode 100644 index 0000000000..e68848e4e5 --- /dev/null +++ b/lib/rules/media-feature-name-value-no-unknown/__tests__/index.js @@ -0,0 +1,308 @@ +'use strict'; + +const { messages, ruleName } = require('..'); + +testRule({ + ruleName, + config: [true], + + accept: [ + { + code: '@media screen and (min-width: 768px) {}', + description: 'Basic media feature', + }, + { + code: '@media screen and (min-width: 0) {}', + description: 'Length of zero, without a unit', + }, + { + code: '@media screen and ( min-resolution : 2dpcm ) {}', + description: 'A resolution', + }, + { + code: '@media screen and ( -moz-min-resolution : 2dpcm ) {}', + description: 'A resolution with a vendor prefix', + }, + { + code: '@media screen and (resolution: 10.1dpcm) {}', + description: 'A resolution with a floating point value', + }, + { + code: '@media screen and (min-width: $sm) {}', + description: 'Non-standard syntax is not checked by this rule', + }, + { + code: '@media (color) {}', + description: 'Boolean context', + }, + { + code: '@media (color : 1) {}', + description: 'Integer value', + }, + { + code: '@media (aspect-ratio : 1 / 1) {}', + description: 'Aspect ratio value', + }, + { + code: '@media (aspect-ratio : 1.2) {}', + description: 'Float value for aspect ratio', + }, + { + code: '@media (aspect-ratio : 1 / calc(Pi)) {}', + description: 'Math expression in aspect ratio', + }, + { + code: '@media (width: max(sin(90deg) * 10px, 100px)) {}', + description: 'Math expression in length', + }, + { + code: '@media screen and (min-width <= 768px) {}', + description: 'Range context, media feature in allowed list', + }, + { + code: '@media (hover: #foo) {}', + description: 'General enclosed because hash tokens are invalid syntax', + }, + { + code: '@media (hover: 10%) {}', + description: 'General enclosed because percentage tokens are invalid syntax', + }, + { + code: '@media screen and (min-width: env(some-width-variable)) and (hover: env(some-width-variable)) and (aspect-ratio < env(some-number-variable) / 100) {}', + description: 'Environment variables', + }, + { + code: '@media (-webkit-hover: -webkit-hover) {}', + description: 'Double vendor prefixes when valid', + }, + { + code: '@media (foo: 100px) {}', + description: 'Unknown media feature', + }, + + // https://www.codeinwp.com/blog/how-to-use-css-media-queries/#less-common-media-features + { code: '@media (width: 800px) {}' }, + { code: '@media (min-width: 360px) {}' }, + { code: '@media (max-width: 1400px) {}' }, + { code: '@media (height: 500px) {}' }, + { code: '@media (min-height: 400px) {}' }, + { code: '@media (max-height: 400px) {}' }, + { code: '@media (orientation: portrait) {}' }, + { code: '@media (orientation: landscape) {}' }, + { code: '@media (display-mode: fullscreen) {}' }, + { code: '@media (display-mode: standalone) {}' }, + { code: '@media (display-mode: minimal-ui) {}' }, + { code: '@media (display-mode: browser) {}' }, + { code: '@media (aspect-ratio: 16/9) {}' }, + { code: '@media (color) {}' }, + { code: '@media (color: 8) {}' }, + { code: '@media (min-color: 8) {}' }, + { code: '@media (max-color: 16) {}' }, + { code: '@media (grid: 0) {}' }, + { code: '@media (grid: 1) {}' }, + { code: '@media (monochrome: 0) {}' }, + { code: '@media (monochrome) {}' }, + { code: '@media (resolution: 72dpi) {}' }, + { code: '@media (min-resolution: 300dpi) {}' }, + { code: '@media (max-resolution: 150dpi) {}' }, + { code: '@media (any-hover: none) {}' }, + { code: '@media (any-hover: hover) {}' }, + { code: '@media (any-pointer: none) {}' }, + { code: '@media (any-pointer: coarse) {}' }, + { code: '@media (any-pointer: fine) {}' }, + { code: '@media (color-gamut: srgb) {}' }, + { code: '@media (color-gamut: p3) {}' }, + { code: '@media (color-gamut: rec2020) {}' }, + { code: '@media (min-color-index: 15000) {}' }, + { code: '@media (max-color-index: 30000) {}' }, + { code: '@media (hover: none) {}' }, + { code: '@media (hover: hover) {}' }, + { code: '@media (overflow-block: none) {}' }, + { code: '@media (overflow-block: scroll) {}' }, + { code: '@media (overflow-block: paged) {}' }, + { code: '@media (overflow-inline: none) {}' }, + { code: '@media (overflow-inline: scroll) {}' }, + { code: '@media (pointer: none) {}' }, + { code: '@media (pointer: coarse) {}' }, + { code: '@media (pointer: fine) {}' }, + { code: '@media (scan: interlace) {}' }, + { code: '@media (scan: progressive) {}' }, + { code: '@media (update: none) {}' }, + { code: '@media (update: slow) {}' }, + { code: '@media (update: fast) {}' }, + { code: '@media (dynamic-range: standard) {}' }, + { code: '@media (dynamic-range: high) {}' }, + { code: '@media (forced-color: none) {}' }, + { code: '@media (forced-color: active) {}' }, + { code: '@media (horizontal-viewport-segments: 3) {}' }, + { code: '@media (environment-blending: opaque) {}' }, + { code: '@media (environment-blending: additive) {}' }, + { code: '@media (environment-blending: subtractive) {}' }, + { code: '@media (inverted-colors: none) {}' }, + { code: '@media (inverted-colors: inverted) {}' }, + { code: '@media (nav-controls: none) {}' }, + { code: '@media (nav-controls: back) {}' }, + { code: '@media (prefers-contrast: no-preference) {}' }, + { code: '@media (prefers-contrast: more) {}' }, + { code: '@media (prefers-contrast: less) {}' }, + { code: '@media (prefers-color-scheme: light) {}' }, + { code: '@media (prefers-color-scheme: dark) {}' }, + { code: '@media (prefers-reduced-data: no-preference) {}' }, + { code: '@media (prefers-reduced-data: reduce) {}' }, + { code: '@media (prefers-reduced-motion: no-preference) {}' }, + { code: '@media (prefers-reduced-motion: reduce) {}' }, + { code: '@media (prefers-reduced-transparency: no-preference) {}' }, + { code: '@media (prefers-reduced-transparency: reduce) {}' }, + { code: '@media (scripting: none) {}' }, + { code: '@media (scripting: initial-only) {}' }, + { code: '@media (scripting: enabled) {}' }, + { code: '@media (vertical-viewport-segments: 3) {}' }, + { code: '@media (video-color-gamut: srgb) {}' }, + { code: '@media (video-color-gamut: p3) {}' }, + { code: '@media (video-color-gamut: rec2020) {}' }, + { code: '@media (video-dynamic-range: standard) {}' }, + { code: '@media (video-dynamic-range: high) {}' }, + ], + + reject: [ + { + code: '@media screen and (min-width: 1000khz) {}', + description: 'Frequency value when only lengths are allowed', + message: messages.rejected('min-width', '1000khz'), + line: 1, + column: 31, + endLine: 1, + endColumn: 38, + }, + { + code: '@media screen and (width <= 1000khz) {}', + description: 'Frequency value when only lengths are allowed in a range context', + message: messages.rejected('width', '1000khz'), + line: 1, + column: 29, + endLine: 1, + endColumn: 36, + }, + { + code: '@media screen and (-webkit-width = 1000khz) {}', + description: 'Validates vendor-prefixed media features', + message: messages.rejected('-webkit-width', '1000khz'), + line: 1, + column: 36, + endLine: 1, + endColumn: 43, + }, + { + code: '@media (-webkit-hover: -webkit-pointer) {}', + description: 'Double vendor prefixes when invalid', + message: messages.rejected('-webkit-hover', '-webkit-pointer'), + line: 1, + column: 24, + endLine: 1, + endColumn: 39, + }, + { + code: '@media (color: 1.1) {}', + description: 'Float value when only integers are allowed', + message: messages.rejected('color', '1.1'), + line: 1, + column: 16, + endLine: 1, + endColumn: 19, + }, + { + code: '@media (color: 1 / 1) {}', + description: 'Aspect ratio value when only integers are allowed', + message: messages.rejected('color', '1 / 1'), + line: 1, + column: 16, + endLine: 1, + endColumn: 21, + }, + { + code: '@media (hover: 15) {}', + description: 'Integer when only keywords are allowed', + message: messages.rejected('hover', '15'), + line: 1, + column: 16, + endLine: 1, + endColumn: 18, + }, + { + code: '@media (hover: /* a comment */15) {}', + description: 'Correct offsets when comments are present', + message: messages.rejected('hover', '15'), + line: 1, + column: 31, + endLine: 1, + endColumn: 33, + }, + { + code: '@media (width: 15) {}', + description: 'Integer when only lengths are allowed', + message: messages.rejected('width', '15'), + line: 1, + column: 16, + endLine: 1, + endColumn: 18, + }, + { + code: '@media (hover: 10px) {}', + description: 'Dimension when only keywords are allowed', + message: messages.rejected('hover', '10px'), + line: 1, + column: 16, + endLine: 1, + endColumn: 20, + }, + { + code: '@media (hover: sin(90deg)) {}', + description: 'Math expressions when only keywords are allowed', + message: messages.rejected('hover', 'sin(90deg)'), + line: 1, + column: 16, + endLine: 1, + endColumn: 26, + }, + { + code: '@media (grid: 2) or (grid: -1) {}', + description: 'Grid only allows "0" or "1"', + warnings: [ + { + message: messages.rejected('grid', '2'), + line: 1, + column: 15, + endLine: 1, + endColumn: 16, + }, + { + message: messages.rejected('grid', '-1'), + line: 1, + column: 28, + endLine: 1, + endColumn: 30, + }, + ], + }, + { + code: '@media (color: purple) and (hover: pointer) {}', + description: 'Incorrect keywords', + warnings: [ + { + message: messages.rejected('color', 'purple'), + line: 1, + column: 16, + endLine: 1, + endColumn: 22, + }, + { + message: messages.rejected('hover', 'pointer'), + line: 1, + column: 36, + endLine: 1, + endColumn: 43, + }, + ], + }, + ], +}); diff --git a/lib/rules/media-feature-name-value-no-unknown/index.js b/lib/rules/media-feature-name-value-no-unknown/index.js new file mode 100644 index 0000000000..235e482820 --- /dev/null +++ b/lib/rules/media-feature-name-value-no-unknown/index.js @@ -0,0 +1,308 @@ +'use strict'; + +const { TokenType, NumberType } = require('@csstools/css-tokenizer'); +const { isTokenNode, isFunctionNode } = require('@csstools/css-parser-algorithms'); +const { + isMediaFeature, + isMediaFeatureValue, + matchesRatioExactly, +} = require('@csstools/media-query-list-parser'); + +const atRuleParamIndex = require('../../utils/atRuleParamIndex'); +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 { lengthUnits, resolutionUnits } = require('../../reference/units'); +const { mathFunctions } = require('../../reference/functions'); +const { + mediaFeatureNameAllowedValueKeywords, + mediaFeatureNameAllowedValueTypes, + mediaFeatureNames, +} = require('../../reference/mediaFeatures'); + +const ruleName = 'media-feature-name-value-no-unknown'; + +const messages = ruleMessages(ruleName, { + rejected: (name, value) => `Unexpected unknown media feature value "${value}" for name "${name}"`, +}); + +const HAS_MIN_MAX_PREFIX = /^(?:min|max)-/i; + +const meta = { + url: 'https://stylelint.io/user-guide/rules/media-feature-name-value-no-unknown', +}; + +/** @typedef {{ mediaFeatureName: string, mediaFeatureNameRaw: string }} State */ +/** @typedef { (state: State, valuePart: string, start: number, end: number) => void } Reporter */ + +/** @type {import('stylelint').Rule} */ +const rule = (primary) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { actual: primary }); + + if (!validOptions) { + return; + } + + /** + * Check that a single token value is valid for a given media feature name. + * + * @param {State} state + * @param {import('@csstools/css-tokenizer').CSSToken} token + * @param {Reporter} reporter + * @returns {void} + */ + function checkSingleToken(state, token, reporter) { + const [type, raw, start, end, parsed] = token; + + if (type === TokenType.Ident) { + const supportedKeywords = mediaFeatureNameAllowedValueKeywords.get(state.mediaFeatureName); + + if (supportedKeywords) { + const keyword = vendor.unprefixed(parsed.value.toLowerCase()); + + if (supportedKeywords.has(keyword)) return; + } + + // An ident that isn't expected for the given media feature name + reporter(state, raw, start, end); + + return; + } + + const supportedValueTypes = mediaFeatureNameAllowedValueTypes.get(state.mediaFeatureName); + + if (!supportedValueTypes) { + // The given media feature name doesn't support any single token values. + reporter(state, raw, start, end); + + return; + } + + if (type === TokenType.Number) { + if (parsed.type === NumberType.Integer) { + if ( + // Integer values are valid for types "integer" and "ratio". + supportedValueTypes.has('integer') || + supportedValueTypes.has('ratio') || + // Integer values of "0" are also valid for "length", "resolution" and "mq-boolean". + (parsed.value === 0 && + (supportedValueTypes.has('length') || + supportedValueTypes.has('resolution') || + supportedValueTypes.has('mq-boolean'))) || + // Integer values of "1" are also valid for "mq-boolean". + (parsed.value === 1 && supportedValueTypes.has('mq-boolean')) + ) { + return; + } + + // An integer when the media feature doesn't support integers. + reporter(state, raw, start, end); + + return; + } + + if ( + // Numbers are valid for "ratio". + supportedValueTypes.has('ratio') || + // Numbers with value "0" are also valid for "length". + (parsed.value === 0 && + (supportedValueTypes.has('length') || supportedValueTypes.has('resolution'))) + ) { + return; + } + + // A number when the media feature doesn't support numbers. + reporter(state, raw, start, end); + + return; + } + + if (type === TokenType.Dimension) { + const unit = parsed.unit.toLowerCase(); + + if (supportedValueTypes.has('resolution') && resolutionUnits.has(unit)) return; + + if (supportedValueTypes.has('length') && lengthUnits.has(unit)) return; + + // An unexpected dimension or a media feature that doesn't support dimensions. + reporter(state, raw, start, end); + } + } + + /** + * Check that a function node is valid for a given media feature name. + * + * @param {State} state + * @param {import('@csstools/css-parser-algorithms').FunctionNode} functionNode + * @param {Reporter} reporter + * @returns {void} + */ + function checkFunction(state, functionNode, reporter) { + const functionName = functionNode.getName().toLowerCase(); + + // "env()" can represent any value, it is treated as valid for static analysis. + if (functionName === 'env') return; + + const supportedValueTypes = mediaFeatureNameAllowedValueTypes.get(state.mediaFeatureName); + + if ( + supportedValueTypes && + mathFunctions.has(functionName) && + (supportedValueTypes.has('integer') || + supportedValueTypes.has('length') || + supportedValueTypes.has('ratio') || + supportedValueTypes.has('resolution')) + ) { + return; + } + + // An unexpected function or a media feature that doesn't support types that can be the result of a function. + reporter(state, functionNode.toString(), ...startAndEndIndex(functionNode)); + } + + /** + * Check that an array of component values is valid for a given media feature name. + * + * @param {State} state + * @param {Array} componentValues + * @param {Reporter} reporter + * @returns {void} + */ + function checkListOfComponentValues(state, componentValues, reporter) { + const supportedValueTypes = mediaFeatureNameAllowedValueTypes.get(state.mediaFeatureName); + + if ( + supportedValueTypes && + supportedValueTypes.has('ratio') && + matchesRatioExactly(componentValues) !== -1 + ) { + return; + } + + // An invalid aspect ratio or a media feature that doesn't support aspect ratios. + reporter( + state, + componentValues.map((x) => x.toString()).join(''), + ...startAndEndIndex(componentValues), + ); + } + + /** + * @param {State} state + * @param {import('@csstools/media-query-list-parser').MediaFeatureValue} valueNode + * @param {Reporter} reporter + * @returns {void} + */ + function checkMediaFeatureValue(state, valueNode, reporter) { + if (isTokenNode(valueNode.value)) { + checkSingleToken(state, valueNode.value.value, reporter); + + return; + } + + if (isFunctionNode(valueNode.value)) { + checkFunction(state, valueNode.value, reporter); + + return; + } + + if (Array.isArray(valueNode.value)) { + checkListOfComponentValues(state, valueNode.value, reporter); + } + } + + root.walkAtRules(/^media$/i, (atRule) => { + /** + * @type {Reporter} + */ + const reporter = (state, valuePart, start, end) => { + const atRuleParamIndexValue = atRuleParamIndex(atRule); + + report({ + message: messages.rejected, + messageArgs: [state.mediaFeatureNameRaw, valuePart], + index: atRuleParamIndexValue + start, + endIndex: atRuleParamIndexValue + end + 1, + node: atRule, + ruleName, + result, + }); + }; + + /** @type {State} */ + const state = { + mediaFeatureName: '', + mediaFeatureNameRaw: '', + }; + + parseMediaQuery(atRule).forEach((mediaQuery) => { + mediaQuery.walk((entry) => { + if (!entry.state) return; + + if (isMediaFeature(entry.node)) { + const mediaFeatureNameRaw = entry.node.getName(); + let mediaFeatureName = vendor.unprefixed(mediaFeatureNameRaw.toLowerCase()); + + // Unknown media feature names are handled by "media-feature-name-no-unknown". + if (!mediaFeatureNames.has(mediaFeatureName)) return; + + mediaFeatureName = mediaFeatureName.replace(HAS_MIN_MAX_PREFIX, ''); + + entry.state.mediaFeatureName = mediaFeatureName; + entry.state.mediaFeatureNameRaw = mediaFeatureNameRaw; + + return; + } + + if (!entry.state.mediaFeatureName || !entry.state.mediaFeatureNameRaw) return; + + if (isMediaFeatureValue(entry.node)) { + checkMediaFeatureValue(entry.state, entry.node, reporter); + } + }, state); + }); + }); + }; +}; + +/** + * A recursive function that returns the start and end boundaries of a node. + * + * @template {import('@csstools/css-tokenizer').CSSToken} T + * @param {Array<{ tokens(): Array }> | { tokens(): Array }} node + * @returns {[number, number]} + */ +function startAndEndIndex(node) { + if (Array.isArray(node)) { + const nodeStart = node[0]; + + if (!nodeStart) return [0, 0]; + + const nodeEnd = node[node.length - 1] || nodeStart; + + const [startA] = startAndEndIndex(nodeStart); + const [, endB] = startAndEndIndex(nodeEnd); + + return [startA, endB]; + } + + const tokens = node.tokens(); + + const firstToken = tokens[0]; + const lastToken = tokens[tokens.length - 1]; + + if (!firstToken || !lastToken) return [0, 0]; + + const start = firstToken[2]; + const end = lastToken[3]; + + return [start, end]; +} + +rule.ruleName = ruleName; +rule.messages = messages; +rule.meta = meta; +module.exports = rule; diff --git a/lib/rules/unit-no-unknown/index.js b/lib/rules/unit-no-unknown/index.js index 59d4c3046b..3855715c06 100644 --- a/lib/rules/unit-no-unknown/index.js +++ b/lib/rules/unit-no-unknown/index.js @@ -24,7 +24,12 @@ const ruleMessages = require('../../utils/ruleMessages'); const validateOptions = require('../../utils/validateOptions'); const vendor = require('../../utils/vendor'); const { isRegExp, isString } = require('../../utils/validateTypes'); -const { units } = require('../../reference/units'); +const units = new Set(require('../../reference/units').units); // a copy that is safe to mutate + +// `x` as a resolution unit is very often a typo for `px`. +// By removing it from the set of known units, we can catch those typos. +// Intentional `x` units are supported by manually checking these in specific functions or properties. +units.delete('x'); const ruleName = 'unit-no-unknown'; diff --git a/lib/utils/parseMediaQuery.js b/lib/utils/parseMediaQuery.js index c88987fff4..0d0dcb1b56 100644 --- a/lib/utils/parseMediaQuery.js +++ b/lib/utils/parseMediaQuery.js @@ -1,14 +1,14 @@ 'use strict'; const { parse } = require('@csstools/media-query-list-parser'); +const getAtRuleParams = require('./getAtRuleParams'); /** * @param {import('postcss').AtRule} atRule * @returns {ReturnType} */ module.exports = function parseMediaQuery(atRule) { - const source = atRule.params; - const mediaQueries = parse(source, { + const mediaQueries = parse(getAtRuleParams(atRule), { preserveInvalidMediaQueries: true, });