diff --git a/.changeset/sour-parrots-cry.md b/.changeset/sour-parrots-cry.md new file mode 100644 index 00000000000..5a13faa7633 --- /dev/null +++ b/.changeset/sour-parrots-cry.md @@ -0,0 +1,5 @@ +--- +'@shopify/stylelint-polaris': minor +--- + +Added `custom-property-disallowed-list` rule diff --git a/stylelint-polaris/plugins/custom-property-allowed-list/index.js b/stylelint-polaris/plugins/custom-property-allowed-list/index.js index b42adc4ee79..99a778e385a 100644 --- a/stylelint-polaris/plugins/custom-property-allowed-list/index.js +++ b/stylelint-polaris/plugins/custom-property-allowed-list/index.js @@ -16,18 +16,25 @@ const messages = stylelint.utils.ruleMessages(ruleName, { * @type {stylelint.RuleMessageFunc} */ rejected: (prop, value, prefix, isInvalidProp, invalidValues) => { - if (isInvalidProp) { - return `Unexpected prefix "${prefix}" for defined custom property "${prop}" - Properties with prefixes "--p-" or "--pc-" cannot be defined outside of Polaris"`; - } + const invalidPropertyMessage = isInvalidProp + ? `Unexpected prefix "${prefix}" for defined custom property "${prop}" - Properties with prefixes "--p-" or "--pc-" cannot be defined outside of Polaris"` + : null; - if (invalidValues) { - const plural = invalidValues.length > 1; - return `Unexpected value "${value}" for property "${prop}" - Token${ - plural ? 's' : '' - } ${invalidValues.map((token) => `"${token}"`).join(', ')} ${ - plural ? 'are' : 'is' - } either private or ${plural ? 'do' : 'does'} not exist`; - } + const plural = invalidValues?.length > 1; + + const invalidValueMessage = invalidValues + ? `Unexpected value "${value}" for property "${prop}" - Token${ + plural ? 's' : '' + } ${invalidValues.map((token) => `"${token}"`).join(', ')} ${ + plural ? 'are' : 'is' + } either private or ${plural ? 'do' : 'does'} not exist` + : null; + + const message = [invalidPropertyMessage, invalidValueMessage] + .filter(Boolean) + .join('. '); + + return message; }, }); @@ -82,7 +89,7 @@ const {rule} = stylelint.createPlugin( value, ); - if (isInvalidProperty) { + if (isInvalidProperty || invalidValues) { stylelint.utils.report({ message: messages.rejected( prop, @@ -96,21 +103,6 @@ const {rule} = stylelint.createPlugin( ruleName, }); } - - if (invalidValues) { - stylelint.utils.report({ - message: messages.rejected( - prop, - value, - undefined, - isInvalidProperty, - invalidValues, - ), - node: decl, - result, - ruleName, - }); - } }); }; }, @@ -122,7 +114,9 @@ const {rule} = stylelint.createPlugin( * @returns {string} */ function getCustomPropertyPrefix(property) { - return `--${property.split('-')[2]}-`; + return isCustomProperty(property) + ? `--${property.split('-')[2]}-` + : undefined; } /** diff --git a/stylelint-polaris/plugins/custom-property-allowed-list/index.test.js b/stylelint-polaris/plugins/custom-property-allowed-list/index.test.js index 8256e93d81e..1748c827a86 100644 --- a/stylelint-polaris/plugins/custom-property-allowed-list/index.test.js +++ b/stylelint-polaris/plugins/custom-property-allowed-list/index.test.js @@ -96,5 +96,31 @@ testRule({ endLine: 1, endColumn: 28, }, + { + code: '.a { --p-foo: var(--p-bar); }', + description: 'Using disallowed --p- prefixed custom property and value', + message: messages.rejected('--p-foo', 'var(--p-bar)', '--p-', true, [ + '--p-bar', + ]), + line: 1, + column: 6, + endLine: 1, + endColumn: 28, + }, + { + code: '.a { --p-foo: var(--p-bar) solid var(--p-baz); }', + description: 'Using disallowed --p- prefixed custom property and values', + message: messages.rejected( + '--p-foo', + 'var(--p-bar) solid var(--p-baz)', + '--p-', + true, + ['--p-bar', '--p-baz'], + ), + line: 1, + column: 6, + endLine: 1, + endColumn: 47, + }, ], }); diff --git a/stylelint-polaris/plugins/custom-property-disallowed-list/README.md b/stylelint-polaris/plugins/custom-property-disallowed-list/README.md new file mode 100644 index 00000000000..79391769ee9 --- /dev/null +++ b/stylelint-polaris/plugins/custom-property-disallowed-list/README.md @@ -0,0 +1,36 @@ +## Custom property disallowed list plugin + +### Options: + +```ts +interface PrimaryOptions { + /** + * A list of regular expressions or string literals that match disallowed custom properties. + */ + disallowedProperties?: (string | RegExp)[]; + /** + * A map of properties and their disallowed custom properties represented as a list + * of regular expressions or string literals. + */ + disallowedValues?: {[property: string]: (string | RegExp)[]}; +} +``` + +### Configuration + +```js +const stylelintConfig = { + rules: { + 'polaris/custom-property-disallowed-list': { + disallowedProperties: ['--p-foo'], + disallowedValues: { + width: ['--p-foo', /--p-bar/ /* etc... */], + '/.+/': ['--p-foo', /--p-bar/ /* etc... */], + }, + }, + }, +}; +``` + +> Note: Property keys for `disallowedValues` are evaluated in order. Please ensure that you +> order your property keys from most specific to least specific. diff --git a/stylelint-polaris/plugins/custom-property-disallowed-list/index.js b/stylelint-polaris/plugins/custom-property-disallowed-list/index.js new file mode 100644 index 00000000000..24cd916c618 --- /dev/null +++ b/stylelint-polaris/plugins/custom-property-disallowed-list/index.js @@ -0,0 +1,189 @@ +const stylelint = require('stylelint'); +const valueParser = require('postcss-value-parser'); + +const { + vendorUnprefixed, + matchesStringOrRegExp, + isCustomProperty, + isRegExp, + isString, +} = require('../../utils'); + +const ruleName = 'polaris/custom-property-disallowed-list'; + +const messages = stylelint.utils.ruleMessages(ruleName, { + /** + * @type {stylelint.RuleMessageFunc} + */ + rejected: (prop, value, isInvalidProp, invalidValues) => { + const invalidPropertyMessage = isInvalidProp + ? `Unexpected custom property definition "${prop}"` + : null; + + const plural = invalidValues?.length > 1; + + const invalidValueMessage = invalidValues + ? `Unexpected value${plural ? 's' : ''} ${invalidValues + .map((token) => `"${token}"`) + .join(', ')} for property "${prop}"` + : null; + + const message = [invalidPropertyMessage, invalidValueMessage] + .filter(Boolean) + .join('. '); + + return message; + }, +}); + +/** @typedef {(string | RegExp)[]} DisallowedPatterns */ + +/** + * @typedef {Object} PrimaryOptions + * @property {DisallowedPatterns} [disallowedProperties] + * @property {{[property: string]: DisallowedPatterns}} [disallowedValues] + */ + +const {rule} = stylelint.createPlugin( + ruleName, + /** @param {PrimaryOptions} primary */ + (primary) => { + return (root, result) => { + const validOptions = stylelint.utils.validateOptions( + result, + ruleName, + { + actual: primary.disallowedProperties, + possible: isDisallowedPatterns, + optional: true, + }, + { + actual: primary.disallowedValues, + possible: validateDisallowedValuesOption, + optional: true, + }, + ); + + if (!validOptions) { + return; + } + + const {disallowedProperties = [], disallowedValues = {}} = primary; + + root.walkDecls((decl) => { + const prop = decl.prop; + const value = decl.value; + + const isInvalidProperty = isInvalidCustomProperty( + disallowedProperties, + prop, + ); + + const invalidValues = getInvalidCustomPropertyValues( + disallowedValues, + prop, + value, + ); + + if (isInvalidProperty || invalidValues) { + stylelint.utils.report({ + message: messages.rejected( + prop, + value, + isInvalidProperty, + invalidValues, + ), + node: decl, + result, + ruleName, + }); + } + }); + }; + }, +); + +/** + * @param {NonNullable} disallowedProperties + * @param {string} property + */ +function isInvalidCustomProperty(disallowedProperties, property) { + if (!isCustomProperty(property)) return false; + + const isInvalid = disallowedProperties.some((disallowedProperty) => { + return matchesStringOrRegExp(property, disallowedProperty); + }); + + return isInvalid; +} + +/** + * @param {NonNullable} disallowedValues + * @param {string} prop + * @param {string} value + * @returns {string[] | undefined} + */ +function getInvalidCustomPropertyValues(disallowedValues, prop, value) { + const invalidValues = []; + + const unprefixedProp = vendorUnprefixed(prop); + + /** Property key for the disallowed values option */ + const propKey = Object.keys(disallowedValues).find((propIdentifier) => + matchesStringOrRegExp(unprefixedProp, propIdentifier), + ); + + if (!propKey) return; + + const disallowedPatterns = disallowedValues[propKey]; + + if (!disallowedPatterns.length) return; + + valueParser(value).walk((node) => { + if ( + node.type === 'word' && + isCustomProperty(node.value) && + matchesStringOrRegExp(node.value, disallowedPatterns) + ) { + invalidValues.push(node.value); + } + }); + + if (invalidValues.length > 0) return invalidValues; +} + +module.exports = { + rule, + ruleName, + messages, +}; + +/** + * Validates the input is an array of String or RegExp. + * @param {unknown} disallowedPatterns + * @returns {disallowedPatterns is DisallowedPatterns} + */ +function isDisallowedPatterns(disallowedPatterns) { + if (!Array.isArray(disallowedPatterns)) return false; + + for (const pattern of disallowedPatterns) { + if (!(isString(pattern) || isRegExp(pattern))) return false; + } + + return true; +} + +/** + * @param {unknown} option - `primary.disallowedValues` option. + */ +function validateDisallowedValuesOption(option) { + if (typeof option !== 'object' || option === null) return false; + + for (const [property, disallowedPatterns] of Object.entries(option)) { + if (!(isString(property) && isDisallowedPatterns(disallowedPatterns))) { + return false; + } + } + + return true; +} diff --git a/stylelint-polaris/plugins/custom-property-disallowed-list/index.test.js b/stylelint-polaris/plugins/custom-property-disallowed-list/index.test.js new file mode 100644 index 00000000000..e9d647382e8 --- /dev/null +++ b/stylelint-polaris/plugins/custom-property-disallowed-list/index.test.js @@ -0,0 +1,108 @@ +const {messages, ruleName} = require('.'); + +const config = [ + { + disallowedProperties: ['--p-foo', /--p-bar/], + disallowedValues: { + '/.+/': ['--p-foo', /--p-bar/], + }, + }, +]; + +testRule({ + ruleName, + plugins: [__dirname], + config, + accept: [ + { + code: '.a { --p-foo-bar: red; }', + description: 'Defining allowed custom property', + }, + { + code: '.a { color: var(--p-foo-bar); }', + description: 'Defining allowed custom property value', + }, + { + code: '.a { --p-foo-bar: var(--p-foo-bar); }', + description: 'Defining allowed custom property and value', + }, + ], + + reject: [ + { + code: '.a { --p-foo: red; }', + description: 'Defining disallowed custom property (literal match)', + message: messages.rejected('--p-foo', 'red', true, undefined), + line: 1, + column: 6, + endLine: 1, + endColumn: 19, + }, + { + code: '.a { --p-bar-baz: red; }', + description: 'Defining disallowed custom property (regex match)', + message: messages.rejected('--p-bar-baz', 'red', true, undefined), + line: 1, + column: 6, + endLine: 1, + endColumn: 23, + }, + { + code: '.a { color: var(--p-foo); }', + description: 'Defining disallowed custom property value (literal match)', + message: messages.rejected('color', 'var(--p-foo)', false, ['--p-foo']), + line: 1, + column: 6, + endLine: 1, + endColumn: 26, + }, + { + code: '.a { color: var(--p-bar-baz); }', + description: 'Defining disallowed custom property value (regex match)', + message: messages.rejected('color', 'var(--p-bar-baz)', false, [ + '--p-bar-baz', + ]), + line: 1, + column: 6, + endLine: 1, + endColumn: 30, + }, + { + code: '.a { --p-foo: var(--p-bar); }', + description: 'Defining disallowed custom property and value', + message: messages.rejected('--p-foo', 'var(--p-bar)', true, ['--p-bar']), + line: 1, + column: 6, + endLine: 1, + endColumn: 28, + }, + { + code: '.a { border: var(--p-foo) solid var(--p-bar); }', + description: 'Defining multiple disallowed custom property values', + message: messages.rejected( + 'border', + 'var(--p-foo) solid var(--p-bar)', + false, + ['--p-foo', '--p-bar'], + ), + line: 1, + column: 6, + endLine: 1, + endColumn: 46, + }, + { + code: '.a { --p-foo: var(--p-foo) solid var(--p-bar); }', + description: 'Defining multiple disallowed custom property and values', + message: messages.rejected( + '--p-foo', + 'var(--p-foo) solid var(--p-bar)', + true, + ['--p-foo', '--p-bar'], + ), + line: 1, + column: 6, + endLine: 1, + endColumn: 47, + }, + ], +});