diff --git a/.changeset/weak-islands-wait.md b/.changeset/weak-islands-wait.md new file mode 100644 index 00000000000..041545e55c9 --- /dev/null +++ b/.changeset/weak-islands-wait.md @@ -0,0 +1,5 @@ +--- +'@shopify/stylelint-polaris': minor +--- + +Add `stylelint-polaris/coverage` rule diff --git a/stylelint-polaris/plugins/coverage/index.js b/stylelint-polaris/plugins/coverage/index.js new file mode 100644 index 00000000000..e755dc125a5 --- /dev/null +++ b/stylelint-polaris/plugins/coverage/index.js @@ -0,0 +1,70 @@ +const stylelint = require('stylelint'); + +const {isObject} = require('../../utils'); + +const ruleName = 'stylelint-polaris/coverage'; + +/** + * @typedef {{ + * [category: string]: import('stylelint').ConfigRules + * }} PrimaryOptions + */ + +module.exports = stylelint.createPlugin( + ruleName, + /** @param {PrimaryOptions} primaryOptions */ + (primaryOptions) => { + const isPrimaryOptionsValid = validatePrimaryOptions(primaryOptions); + + const rules = !isPrimaryOptionsValid + ? [] + : Object.entries(primaryOptions).flatMap( + ([categoryName, categoryConfigRules]) => + Object.entries(categoryConfigRules).map( + ([categoryRuleName, categoryRuleSettings]) => ({ + categoryRuleName, + categoryRuleSettings, + coverageRuleName: `${ruleName}/${categoryName}`, + }), + ), + ); + + return (root, result) => { + const validOptions = stylelint.utils.validateOptions(result, ruleName, { + actual: isPrimaryOptionsValid, + }); + + if (!validOptions) return; + + for (const rule of rules) { + const {categoryRuleName, categoryRuleSettings, coverageRuleName} = rule; + + stylelint.utils.checkAgainstRule( + { + ruleName: categoryRuleName, + ruleSettings: categoryRuleSettings, + root, + }, + (warning) => { + stylelint.utils.report({ + result, + node: warning.node, + ruleName: coverageRuleName, + message: warning.text.replace(categoryRuleName, coverageRuleName), + }); + }, + ); + } + }; + }, +); + +function validatePrimaryOptions(primaryOptions) { + if (!isObject(primaryOptions)) return false; + + for (const categoryConfigRules of Object.values(primaryOptions)) { + if (!isObject(categoryConfigRules)) return false; + } + + return true; +} diff --git a/stylelint-polaris/plugins/coverage/index.test.js b/stylelint-polaris/plugins/coverage/index.test.js new file mode 100644 index 00000000000..4f8973f0d35 --- /dev/null +++ b/stylelint-polaris/plugins/coverage/index.test.js @@ -0,0 +1,29 @@ +const {ruleName} = require('.'); + +const config = { + motion: { + 'at-rule-disallowed-list': [['keyframes'], {severity: 'warning'}], + }, +}; + +testRule({ + ruleName, + plugins: [__dirname], + config, + customSyntax: 'postcss-scss', + accept: [ + { + code: '@media (min-width: 320px) {}', + description: 'Uses allowed at-rule', + }, + ], + + reject: [ + { + code: '@keyframes foo {}', + description: 'Uses disallowed at-rule', + message: + 'Unexpected at-rule "keyframes" (stylelint-polaris/coverage/motion)', + }, + ], +}); diff --git a/stylelint-polaris/utils/index.js b/stylelint-polaris/utils/index.js index de77d65c40d..25eb90bccfd 100644 --- a/stylelint-polaris/utils/index.js +++ b/stylelint-polaris/utils/index.js @@ -181,6 +181,17 @@ function isNumber(value) { return typeof value === 'number' || value instanceof Number; } +/** + * Checks if the value is an object and not an array or null. + * https://github.com/jonschlinkert/isobject/blob/15d5d58ea9fbc632dffd52917ac6791cd92251ab/index.js#L9 + * @param {unknown} value + */ +function isObject(value) { + return ( + value != null && typeof value === 'object' && Array.isArray(value) === false + ); +} + /** * Checks if the value is a RegExp object. * @param {unknown} value @@ -212,6 +223,7 @@ module.exports.hasScssInterpolation = hasScssInterpolation; module.exports.isBoolean = isBoolean; module.exports.isCustomProperty = isCustomProperty; module.exports.isNumber = isNumber; +module.exports.isObject = isObject; module.exports.isRegExp = isRegExp; module.exports.isScssInterpolation = isScssInterpolation; module.exports.isString = isString;