From 8051822e502f75f62b812ad6cb83f613dbd5d843 Mon Sep 17 00:00:00 2001 From: Romain Menke <11521496+romainmenke@users.noreply.github.com> Date: Fri, 30 Jun 2023 09:22:17 +0200 Subject: [PATCH] Refactor `media-feature-name-value-allowed-list` to replace `postcss-media-query-parser` with `@csstools/media-query-list-parser` (#6999) Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com> --- .changeset/fifty-hotels-admire.md | 5 + docs/user-guide/errors.md | 2 +- .../__tests__/index.js | 31 +++--- .../index.js | 99 ++++++++++++------- lib/rules/rangeContextNodeParser.js | 59 ----------- .../isRangeContextMediaFeature.test.js | 17 ---- lib/utils/isRangeContextMediaFeature.js | 11 --- package-lock.json | 24 ----- package.json | 2 - 9 files changed, 85 insertions(+), 165 deletions(-) create mode 100644 .changeset/fifty-hotels-admire.md delete mode 100644 lib/rules/rangeContextNodeParser.js delete mode 100644 lib/utils/__tests__/isRangeContextMediaFeature.test.js delete mode 100644 lib/utils/isRangeContextMediaFeature.js diff --git a/.changeset/fifty-hotels-admire.md b/.changeset/fifty-hotels-admire.md new file mode 100644 index 0000000000..45a2e672d0 --- /dev/null +++ b/.changeset/fifty-hotels-admire.md @@ -0,0 +1,5 @@ +--- +"stylelint": patch +--- + +Removed: `postcss-media-query-parser` dependency diff --git a/docs/user-guide/errors.md b/docs/user-guide/errors.md index a319112927..fa3e648b92 100644 --- a/docs/user-guide/errors.md +++ b/docs/user-guide/errors.md @@ -65,7 +65,7 @@ The CSS parser built into Stylelint (or the chosen [custom syntax](options.md#cu The construct-specific parsers are: -- `postcss-media-query-parser` +- `@csstools/media-query-list-parser` - `postcss-selector-parser` - `postcss-value-parser` diff --git a/lib/rules/media-feature-name-value-allowed-list/__tests__/index.js b/lib/rules/media-feature-name-value-allowed-list/__tests__/index.js index 398afff178..65faa7b111 100644 --- a/lib/rules/media-feature-name-value-allowed-list/__tests__/index.js +++ b/lib/rules/media-feature-name-value-allowed-list/__tests__/index.js @@ -6,7 +6,7 @@ testRule({ ruleName, config: [ { - 'min-width': ['768px', '$sm'], + 'min-width': ['768px'], '/resolution/': '/dpcm$/', // Only dpcm unit color: [], // Test boolean context width: [], // Test range context @@ -35,8 +35,8 @@ testRule({ description: 'Floating point value', }, { - code: '@media screen and (min-width: $sm) {}', - description: 'Non-standard syntax in allowed list', + code: '@media screen and (-webkit-resolution: 10.1dpcm) {}', + description: 'Prefixed media feature', }, { code: '@media (color) {}', @@ -66,16 +66,16 @@ testRule({ endColumn: 37, }, { - code: '@media screen (min-width: 768px) and (min-width: 1000px) {}', + code: '@media screen and (min-width: 768px) and (min-width: 1000px) {}', description: 'Media feature multiple', message: messages.rejected('min-width', '1000px'), line: 1, - column: 50, + column: 54, endLine: 1, - endColumn: 56, + endColumn: 60, }, { - code: '@media screen (min-width: 768px)\nand (min-width: 1000px) {}', + code: '@media screen and (min-width: 768px)\nand (min-width: 1000px) {}', description: 'Media feature multiline', message: messages.rejected('min-width', '1000px'), line: 2, @@ -92,15 +92,6 @@ testRule({ endLine: 1, endColumn: 36, }, - { - code: '@media screen and (min-width: $md) {}', - description: 'Non-standard syntax NOT in allowed list', - message: messages.rejected('min-width', '$md'), - line: 1, - column: 31, - endLine: 1, - endColumn: 34, - }, { code: '@media screen and (min-resolution: 2dpi) {}', message: messages.rejected('min-resolution', '2dpi'), @@ -109,6 +100,14 @@ testRule({ endLine: 1, endColumn: 41, }, + { + code: '@media screen and (-webkit-min-resolution: 2dpi) {}', + message: messages.rejected('-webkit-min-resolution', '2dpi'), + line: 1, + column: 45, + endLine: 1, + endColumn: 49, + }, { code: '@media screen and (min-width > 500px) {}', message: messages.rejected('min-width', '500px'), diff --git a/lib/rules/media-feature-name-value-allowed-list/index.js b/lib/rules/media-feature-name-value-allowed-list/index.js index d00a7d6fb7..6972b4df70 100644 --- a/lib/rules/media-feature-name-value-allowed-list/index.js +++ b/lib/rules/media-feature-name-value-allowed-list/index.js @@ -1,18 +1,21 @@ 'use strict'; -const mediaParser = require('postcss-media-query-parser').default; +const { + isMediaQueryInvalid, + isMediaFeature, + isMediaFeatureValue, +} = require('@csstools/media-query-list-parser'); const atRuleParamIndex = require('../../utils/atRuleParamIndex'); -const isRangeContextMediaFeature = require('../../utils/isRangeContextMediaFeature'); const matchesStringOrRegExp = require('../../utils/matchesStringOrRegExp'); 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 validateObjectWithArrayProps = require('../../utils/validateObjectWithArrayProps'); const validateOptions = require('../../utils/validateOptions'); -const { isString, isRegExp } = require('../../utils/validateTypes'); const vendor = require('../../utils/vendor'); +const { isString, isRegExp } = require('../../utils/validateTypes'); const ruleName = 'media-feature-name-value-allowed-list'; @@ -37,40 +40,32 @@ const rule = (primary) => { } root.walkAtRules(/^media$/i, (atRule) => { - mediaParser(atRule.params).walk(/^media-feature-expression$/i, (node) => { - if (!node.nodes) return; - - const mediaFeatureRangeContext = isRangeContextMediaFeature(node.parent.value); - - // Ignore boolean - if (!node.value.includes(':') && !mediaFeatureRangeContext) { - return; - } + parseMediaQuery(atRule).forEach((mediaQuery) => { + if (isMediaQueryInvalid(mediaQuery)) return; - const mediaFeatureNode = node.nodes.find((n) => n.type === 'media-feature'); + const initialState = { + mediaFeatureName: '', + unprefixedMediaFeatureName: '', + }; - if (mediaFeatureNode == null) throw new Error('A `media-feature` node must be present'); + mediaQuery.walk(({ node, state }) => { + if (!state) return; - let mediaFeatureName; - let values; + if (isMediaFeature(node)) { + state.mediaFeatureName = node.getName(); + state.unprefixedMediaFeatureName = vendor.unprefixed(node.getName()); - if (mediaFeatureRangeContext) { - const parsedRangeContext = rangeContextNodeParser(mediaFeatureNode); + return; + } - mediaFeatureName = parsedRangeContext.name.value; - values = parsedRangeContext.values; - } else { - mediaFeatureName = mediaFeatureNode.value; - const valueNode = node.nodes.find((n) => n.type === 'value'); + if (!isMediaFeatureValue(node)) return; - if (valueNode == null) throw new Error('A `value` node must be present'); + const { mediaFeatureName, unprefixedMediaFeatureName } = state; - values = [valueNode]; - } + if (!mediaFeatureName || !unprefixedMediaFeatureName) return; - for (const valueNode of values) { - const value = valueNode.value; - const unprefixedMediaFeatureName = vendor.unprefixed(mediaFeatureName); + const componentValues = [node.value].flat(); + const value = componentValues.map((x) => x.toString()).join(''); const allowedValuesKey = Object.keys(primary).find((featureName) => matchesStringOrRegExp(unprefixedMediaFeatureName, featureName), @@ -84,24 +79,58 @@ const rule = (primary) => { return; } - const index = atRuleParamIndex(atRule) + valueNode.sourceIndex; - const endIndex = index + value.length; + const atRuleIndex = atRuleParamIndex(atRule); + const [startIndex, endIndex] = startAndEndIndex(componentValues); report({ - index, - endIndex, + index: atRuleIndex + startIndex, + endIndex: atRuleIndex + endIndex + 1, message: messages.rejected, messageArgs: [mediaFeatureName, value], node: atRule, ruleName, result, }); - } + }, initialState); }); }); }; }; +/** + * 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; diff --git a/lib/rules/rangeContextNodeParser.js b/lib/rules/rangeContextNodeParser.js deleted file mode 100644 index 16270fb8ab..0000000000 --- a/lib/rules/rangeContextNodeParser.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; - -const valueParser = require('postcss-value-parser'); - -const { assert } = require('../utils/validateTypes'); - -const rangeOperators = new Set(['>=', '<=', '>', '<', '=']); - -/** - * @param {string} name - * @returns {boolean} - */ -function isRangeContextName(name) { - // When the node is like "(width > 10em)" or "(10em < width)" - // Regex is needed because the name can either be in the first or second position - return /^(?!--)\D/.test(name) || /^--./.test(name); -} - -/** - * @typedef {{ value: string, sourceIndex: number }} RangeContextNode - * - * @param {import('postcss-media-query-parser').Node} node - * @returns {{ name: RangeContextNode, values: RangeContextNode[] }} - */ -module.exports = function rangeContextNodeParser(node) { - /** @type {import('postcss-value-parser').WordNode | undefined} */ - let nameNode; - - /** @type {import('postcss-value-parser').WordNode[]} */ - const valueNodes = []; - - valueParser(node.value).walk((valueNode) => { - if (valueNode.type !== 'word') return; - - if (rangeOperators.has(valueNode.value)) return; - - if (nameNode == null && isRangeContextName(valueNode.value)) { - nameNode = valueNode; - - return; - } - - valueNodes.push(valueNode); - }); - - assert(nameNode); - - return { - name: { - value: nameNode.value, - sourceIndex: node.sourceIndex + nameNode.sourceIndex, - }, - - values: valueNodes.map((valueNode) => ({ - value: valueNode.value, - sourceIndex: node.sourceIndex + valueNode.sourceIndex, - })), - }; -}; diff --git a/lib/utils/__tests__/isRangeContextMediaFeature.test.js b/lib/utils/__tests__/isRangeContextMediaFeature.test.js deleted file mode 100644 index 81004c629e..0000000000 --- a/lib/utils/__tests__/isRangeContextMediaFeature.test.js +++ /dev/null @@ -1,17 +0,0 @@ -'use strict'; - -const isRangeContextMediaFeature = require('../isRangeContextMediaFeature'); - -it('isRangeContextMediaFeature', () => { - expect(isRangeContextMediaFeature('(width = 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(width > 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(width < 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(HEIGHT >= 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(HEIGHT <= 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(5px > width < 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(5px => HEIGHT <= 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(5px > HEIGHT <= 10px)')).toBeTruthy(); - expect(isRangeContextMediaFeature('(color)')).toBeFalsy(); - expect(isRangeContextMediaFeature('(MONOCHROME)')).toBeFalsy(); - expect(isRangeContextMediaFeature('(min-width: 10px)')).toBeFalsy(); -}); diff --git a/lib/utils/isRangeContextMediaFeature.js b/lib/utils/isRangeContextMediaFeature.js deleted file mode 100644 index 822a5928b4..0000000000 --- a/lib/utils/isRangeContextMediaFeature.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -/** - * Check whether a media feature is a range context one - * - * @param {string} mediaFeature feature - * @return {boolean} If `true`, media feature is a range context one - */ -module.exports = function isRangeContextMediaFeature(mediaFeature) { - return mediaFeature.includes('=') || mediaFeature.includes('<') || mediaFeature.includes('>'); -}; diff --git a/package-lock.json b/package-lock.json index d3695d9579..a4eff48bdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "normalize-path": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.24", - "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.1", "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.0.13", @@ -70,7 +69,6 @@ "@types/micromatch": "^4.0.2", "@types/normalize-path": "^3.0.0", "@types/postcss-less": "^4.0.2", - "@types/postcss-media-query-parser": "^0.2.0", "@types/postcss-resolve-nested-selector": "^0.1.0", "@types/postcss-safe-parser": "^5.0.1", "@types/style-search": "^0.1.3", @@ -2643,12 +2641,6 @@ "postcss": "^8.1.2" } }, - "node_modules/@types/postcss-media-query-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@types/postcss-media-query-parser/-/postcss-media-query-parser-0.2.0.tgz", - "integrity": "sha512-0PMAeq2s7N7g1bc+jAZVv1XbFUjxe88GPhi0gxWA3XPPOPWLOtxFhqwUjZBbsmMbDZlMRQxjiNQGbyzZscb0bw==", - "dev": true - }, "node_modules/@types/postcss-resolve-nested-selector": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@types/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.0.tgz", @@ -12465,11 +12457,6 @@ "postcss": "^8.3.5" } }, - "node_modules/postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ=" - }, "node_modules/postcss-resolve-nested-selector": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz", @@ -18090,12 +18077,6 @@ "postcss": "^8.1.2" } }, - "@types/postcss-media-query-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@types/postcss-media-query-parser/-/postcss-media-query-parser-0.2.0.tgz", - "integrity": "sha512-0PMAeq2s7N7g1bc+jAZVv1XbFUjxe88GPhi0gxWA3XPPOPWLOtxFhqwUjZBbsmMbDZlMRQxjiNQGbyzZscb0bw==", - "dev": true - }, "@types/postcss-resolve-nested-selector": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@types/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.0.tgz", @@ -24982,11 +24963,6 @@ "dev": true, "requires": {} }, - "postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ=" - }, "postcss-resolve-nested-selector": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz", diff --git a/package.json b/package.json index 237d8f5b47..5f29939db6 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,6 @@ "normalize-path": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.24", - "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.1", "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.0.13", @@ -187,7 +186,6 @@ "@types/micromatch": "^4.0.2", "@types/normalize-path": "^3.0.0", "@types/postcss-less": "^4.0.2", - "@types/postcss-media-query-parser": "^0.2.0", "@types/postcss-resolve-nested-selector": "^0.1.0", "@types/postcss-safe-parser": "^5.0.1", "@types/style-search": "^0.1.3",