Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sour-parrots-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/stylelint-polaris': minor
---

Added `custom-property-disallowed-list` rule
50 changes: 22 additions & 28 deletions stylelint-polaris/plugins/custom-property-allowed-list/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
});

Expand Down Expand Up @@ -82,7 +89,7 @@ const {rule} = stylelint.createPlugin(
value,
);

if (isInvalidProperty) {
if (isInvalidProperty || invalidValues) {
stylelint.utils.report({
message: messages.rejected(
prop,
Expand All @@ -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,
});
}
});
};
},
Expand All @@ -122,7 +114,9 @@ const {rule} = stylelint.createPlugin(
* @returns {string}
*/
function getCustomPropertyPrefix(property) {
return `--${property.split('-')[2]}-`;
return isCustomProperty(property)
? `--${property.split('-')[2]}-`
: undefined;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
});
Original file line number Diff line number Diff line change
@@ -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.
189 changes: 189 additions & 0 deletions stylelint-polaris/plugins/custom-property-disallowed-list/index.js
Original file line number Diff line number Diff line change
@@ -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<PrimaryOptions['disallowedProperties']>} 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<PrimaryOptions['disallowedValues']>} 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;
}
Loading