@shopify/stylelint-polaris
Version:
Polaris Design System Stylelint tooling
206 lines (170 loc) • 5.25 kB
JavaScript
const stylelint = require('stylelint');
const valueParser = require('postcss-value-parser');
const {
vendorUnprefixed,
matchesStringOrRegExp,
isCustomProperty,
isRegExp,
isString,
} = require('../../utils');
const ruleName = 'polaris/custom-property-allowed-list';
const messages = stylelint.utils.ruleMessages(ruleName, {
/**
* @type {stylelint.RuleMessageFunc}
*/
rejected: (prop, value, prefix, isInvalidProp, invalidValues) => {
const invalidPropertyMessage = isInvalidProp
? `Unexpected prefix "${prefix}" for defined custom property "${prop}" - Properties with prefixes "--p-" or "--pc-" cannot be defined outside of Polaris"`
: null;
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;
},
});
/** @typedef {(string | RegExp)[]} AllowedPatterns */
/**
* @typedef {Object} PrimaryOptions
* @property {AllowedPatterns} [allowedProperties]
* @property {{[property: string]: AllowedPatterns}} [allowedValues]
*/
const {rule} = stylelint.createPlugin(
ruleName,
/** @param {PrimaryOptions} primary */
(primary) => {
return (root, result) => {
const validOptions = stylelint.utils.validateOptions(
result,
ruleName,
{
actual: primary.allowedProperties,
possible: isAllowedPatterns,
optional: true,
},
{
actual: primary.allowedValues,
possible: validateAllowedValuesOption,
optional: true,
},
);
if (!validOptions) {
throw new Error(
`Invalid options were provided to the [${ruleName}] rule in your Stylelint config.\n`,
);
}
const {allowedProperties = [], allowedValues = {}} = primary;
root.walkDecls((decl) => {
const prop = decl.prop;
const value = decl.value;
const isInvalidProperty = isInvalidCustomProperty(
allowedProperties,
prop,
);
const invalidValues = getInvalidCustomPropertyValues(
allowedValues,
prop,
value,
);
if (isInvalidProperty || invalidValues) {
stylelint.utils.report({
message: messages.rejected(
prop,
value,
getCustomPropertyPrefix(prop),
isInvalidProperty,
invalidValues,
),
node: decl,
result,
ruleName,
});
}
});
};
},
);
/**
* Returns the prefix of a custom property.
* @param {string} property
* @returns {string}
*/
function getCustomPropertyPrefix(property) {
return isCustomProperty(property)
? `--${property.split('-')[2]}-`
: undefined;
}
/**
* @param {NonNullable<PrimaryOptions['allowedProperties']>} allowedProperties
* @param {string} property
*/
function isInvalidCustomProperty(allowedProperties, property) {
if (!isCustomProperty(property)) return false;
const isValid = allowedProperties.some((allowedProperty) => {
return matchesStringOrRegExp(property, allowedProperty);
});
return !isValid;
}
/**
* @param {NonNullable<PrimaryOptions['allowedValues']>} allowedValues
* @param {string} prop
* @param {string} value
* @returns {string[] | undefined}
*/
function getInvalidCustomPropertyValues(allowedValues, prop, value) {
const invalidValues = [];
const unprefixedProp = vendorUnprefixed(prop);
/** Property key for the allowed values option */
const propKey = Object.keys(allowedValues).find((propIdentifier) =>
matchesStringOrRegExp(unprefixedProp, propIdentifier),
);
if (!propKey) return;
const allowedPatterns = allowedValues[propKey];
if (!allowedPatterns.length) return;
valueParser(value).walk((node) => {
if (
node.type === 'word' &&
isCustomProperty(node.value) &&
!matchesStringOrRegExp(node.value, allowedPatterns)
) {
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} allowedPatterns
* @returns {allowedPatterns is AllowedPatterns}
*/
function isAllowedPatterns(allowedPatterns) {
if (!Array.isArray(allowedPatterns)) return false;
for (const pattern of allowedPatterns) {
if (!(isString(pattern) || isRegExp(pattern))) return false;
}
return true;
}
/**
* @param {unknown} option - `primary.allowedValues` option.
*/
function validateAllowedValuesOption(option) {
if (typeof option !== 'object' || option === null) return false;
for (const [property, allowedPatterns] of Object.entries(option)) {
if (!(isString(property) && isAllowedPatterns(allowedPatterns))) {
return false;
}
}
return true;
}