plugins/lib/decl-validator.js (206 lines of code) (raw):

const anymatch = require('anymatch') const valueParser = require('postcss-value-parser') const TapMap = require('tap-map') const SKIP_VALUE_NODE_TYPES = new Set(['space', 'div']) const SKIP_AT_RULE_NAMES = new Set(['each', 'for', 'function', 'mixin']) module.exports = function declarationValidator(rules, options = {}) { const {formatMessage = defaultMessageFormatter, variables, verbose = false} = options const variableReplacements = new TapMap() if (variables) { for (const [name, {values}] of Object.entries(variables)) { for (const value of values) { variableReplacements.tap(value, () => []).push(name) } } } const validators = Object.entries(rules) .map(([key, rule]) => { if (rule === false) { return false } const {name = key, props = name, expects = `a ${name} value`} = rule const replacements = Object.assign({}, rule.replacements, getVariableReplacements(rule.values)) // console.warn(`replacements for "${key}": ${JSON.stringify(replacements)}`) Object.assign(rule, {name, props, expects, replacements}) return { rule, matchesProp: anymatch(props), validate: Array.isArray(rule.components) ? componentValidator(rule) : valueValidator(rule) } }) .filter(Boolean) const validatorsByProp = new TapMap() const validatorsByReplacementValue = new Map() for (const validator of validators) { for (const value of Object.keys(validator.rule.replacements)) { validatorsByReplacementValue.set(value, validator) } } return decl => { if (closest(decl, isSkippableAtRule)) { if (verbose) { // eslint-disable-next-line no-console console.warn(`skipping declaration: ${decl.parent.toString()}`) } // As a general rule, any rule nested in an at-rule is ignored, since // @for, @each, @mixin, and @function blocks can use whatever variables // they want return {valid: true} } const validator = getPropValidator(decl.prop) if (validator) { const result = validator.validate(decl) result.errors = result.errors.map(formatMessage) return result } else { return {valid: true} } } function getVariableReplacements(values) { const replacements = {} const varValues = (Array.isArray(values) ? values : [values]).filter(v => typeof v === 'string' && v.includes('$')) const matches = anymatch(varValues) for (const [value, aliases] of variableReplacements.entries()) { for (const alias of aliases) { if (matches(alias)) { replacements[value] = alias } } } return replacements } function getPropValidator(prop) { return validatorsByProp.tap(prop, () => validators.find(v => v.matchesProp(prop))) } function valueValidator({expects, values, replacements, singular = false}) { const matches = anymatch(values) return function validate({prop, value}, nested) { if (matches(value)) { return { valid: true, errors: [], fixable: false, replacement: undefined } } else if (replacements[value]) { let replacement = value do { replacement = replacements[replacement] } while (replacements[replacement]) return { valid: false, errors: [{expects, prop, value, replacement}], fixable: true, replacement } } else { if (nested || singular) { return { valid: false, errors: [{expects, prop, value}], fixable: false, replacement: undefined } } const parsed = valueParser(value) const validations = parsed.nodes .map((node, index) => Object.assign(node, {index})) .filter(node => !SKIP_VALUE_NODE_TYPES.has(node.type)) .map(node => { const validation = validate({prop, value: valueParser.stringify(node)}, true) validation.index = node.index return validation }) const valid = validations.every(v => v.valid) if (valid) { return {valid, errors: [], fixable: false, replacement: undefined} } const fixable = validations.some(v => v.fixable) const errors = validations.reduce((list, v) => list.concat(v.errors), []) let replacement = undefined for (const validation of validations) { if (fixable && validation.replacement) { parsed.nodes[validation.index] = {type: 'word', value: validation.replacement} } } if (fixable) { replacement = valueParser.stringify(parsed) } return { valid, fixable, errors, replacement } } } } function componentValidator({expects, components, values, replacements}) { const matchesCompoundValue = anymatch(values) return decl => { const {prop, value: compoundValue} = decl const parsed = valueParser(compoundValue) if (parsed.nodes.length === 1 && matchesCompoundValue(compoundValue)) { return {valid: true, errors: []} } const errors = [] let fixable = false let componentIndex = 0 for (const [index, node] of Object.entries(parsed.nodes)) { if (SKIP_VALUE_NODE_TYPES.has(node.type)) { continue } const value = valueParser.stringify(node) let componentProp = components[componentIndex++] let validator = getPropValidator(componentProp) if (validatorsByReplacementValue.has(value)) { validator = validatorsByReplacementValue.get(value) componentProp = validator.rule.name } const nestedProp = `${componentProp} (in ${prop})` if (validator) { const result = validator.validate({prop: nestedProp, value}, true) if (result.replacement) { parsed.nodes[index] = { type: 'word', value: result.replacement } fixable = true } for (const error of result.errors) { errors.push(error) } } else { errors.push({expects, prop: nestedProp, value}) } } let replacement = fixable ? valueParser.stringify(parsed) : undefined // if a compound replacement exists, suggest *that* instead if (replacement && replacements[replacement]) { do { replacement = replacements[replacement] } while (replacements[replacement]) return { valid: false, errors: [{expects, prop, value: compoundValue, replacement}], fixable: true, replacement } } return { valid: errors.length === 0, errors, fixable, replacement } } } function isSkippableAtRule(node) { return node.type === 'atrule' && SKIP_AT_RULE_NAMES.has(node.name) } } function defaultMessageFormatter(error) { const {expects, value, replacement} = error const expected = replacement ? `"${replacement}"` : expects return `Please use ${expected} instead of "${value}"` } function closest(node, test) { let ancestor = node do { if (test(ancestor)) return ancestor } while ((ancestor = ancestor.parent)) }