UNPKG

stylelint

Version:

A mighty CSS linter that helps you avoid errors and enforce conventions.

418 lines (340 loc) 12.1 kB
import valueParser from 'postcss-value-parser'; import { assert, isRegExp, isString } from '../../utils/validateTypes.mjs'; import arrayEqual from '../../utils/arrayEqual.mjs'; import { basicKeywords } from '../../reference/keywords.mjs'; import eachDeclarationBlock from '../../utils/eachDeclarationBlock.mjs'; import isComma from '../../utils/isComma.mjs'; import { longhandSubPropertiesOfShorthandProperties } from '../../reference/properties.mjs'; import optionsMatches from '../../utils/optionsMatches.mjs'; import report from '../../utils/report.mjs'; import ruleMessages from '../../utils/ruleMessages.mjs'; import validateOptions from '../../utils/validateOptions.mjs'; import vendor from '../../utils/vendor.mjs'; const ruleName = 'declaration-block-no-redundant-longhand-properties'; const messages = ruleMessages(ruleName, { expected: (property) => `Expected shorthand property "${property}"`, unexpectedLonghand: (longhand, shorthand) => `Redundant longhand property "${longhand}" after shorthand property "${shorthand}"`, }); const meta = { url: 'https://stylelint.io/user-guide/rules/declaration-block-no-redundant-longhand-properties', fixable: true, }; /** @import { Declaration } from 'postcss' */ /** @import { Node as ValueNode } from 'postcss-value-parser' */ /** @import { ShorthandProperties } from 'stylelint' */ /** @type {Map<string, (decls: Map<string, Declaration>) => (string | undefined)>} */ const customResolvers = new Map([ [ 'font-synthesis', (decls) => { const weight = decls.get('font-synthesis-weight')?.value.trim(); const style = decls.get('font-synthesis-style')?.value.trim(); const smallCaps = decls.get('font-synthesis-small-caps')?.value.trim(); /** @type {(s: string | undefined) => boolean} */ const isValidFontSynthesisValue = (s) => s === 'none' || s === 'auto'; if ( !isValidFontSynthesisValue(weight) || !isValidFontSynthesisValue(style) || !isValidFontSynthesisValue(smallCaps) ) { return; } const autoShorthands = []; if (weight === 'auto') { autoShorthands.push('weight'); } if (style === 'auto') { autoShorthands.push('style'); } if (smallCaps === 'auto') { autoShorthands.push('small-caps'); } if (autoShorthands.length === 0) return 'none'; return autoShorthands.join(' '); }, ], [ 'grid-column', (decls) => { const start = decls.get('grid-column-start')?.value.trim(); const end = decls.get('grid-column-end')?.value.trim(); if (!start || !end) return; return `${start} / ${end}`; }, ], [ 'grid-row', (decls) => { const start = decls.get('grid-row-start')?.value.trim(); const end = decls.get('grid-row-end')?.value.trim(); if (!start || !end) return; return `${start} / ${end}`; }, ], [ 'grid-template', (decls) => { const areas = decls.get('grid-template-areas')?.value.trim(); const columns = decls.get('grid-template-columns')?.value.trim(); const rows = decls.get('grid-template-rows')?.value.trim(); if (!(areas && columns && rows)) return; // repeat() is not allowed inside track listings for grid-template. // related issue: https://github.com/stylelint/stylelint/issues/7228 // spec ref: https://drafts.csswg.org/css-grid/#explicit-grid-shorthand if (columns.includes('repeat(') || rows.includes('repeat(')) return; const splitAreas = [...areas.matchAll(/"[^"]+"/g)].map((x) => x[0]); const splitRows = rows.split(' '); if (splitAreas.length === 0 || splitRows.length === 0) return; if (splitAreas.length !== splitRows.length) return; const zipped = splitAreas.map((area, i) => `${area} ${splitRows[i]}`).join(' '); return `${zipped} / ${columns}`; }, ], [ 'transition', (decls) => { const delays = commaSeparated(decls.get('transition-delay')?.value); const durations = commaSeparated(decls.get('transition-duration')?.value); const timingFunctions = commaSeparated(decls.get('transition-timing-function')?.value); const properties = commaSeparated(decls.get('transition-property')?.value); if (!(delays.length && durations.length && timingFunctions.length && properties.length)) { return; } // transition-property is the canonical list of the number of properties; // see spec: https://w3c.github.io/csswg-drafts/css-transitions/#transition-property-property // if there are more transition-properties than duration/delay/timings, // the other properties are computed cyclically -- ex with % // see spec example #3: https://w3c.github.io/csswg-drafts/css-transitions/#example-d94cbd75 return properties .map((property, i) => { return [ property, durations[i % durations.length], timingFunctions[i % timingFunctions.length], delays[i % delays.length], ] .filter(isString) .join(' '); }) .join(', '); }, ], ]); const OVERLAPPING_SHORTHANDS = new Set([ 'border-width', 'border-style', 'border-color', 'border-top', 'border-right', 'border-bottom', 'border-left', 'grid-column', 'grid-row', ]); /** @type {import('stylelint').CoreRules[ruleName]} */ const rule = (primary, secondaryOptions) => { return (root, result) => { const validOptions = validateOptions( result, ruleName, { actual: primary }, { actual: secondaryOptions, possible: { ignoreShorthands: [isString, isRegExp], ignoreLonghands: [isString], }, optional: true, }, ); if (!validOptions) { return; } /** @type {Map<string, ShorthandProperties[]>} */ const longhandToShorthands = new Map(); const ignoreLonghands = [secondaryOptions?.ignoreLonghands ?? []].flat(); for (const [shorthand, longhandProps] of longhandSubPropertiesOfShorthandProperties.entries()) { if (optionsMatches(secondaryOptions, 'ignoreShorthands', shorthand)) continue; for (const longhand of longhandProps) { if (ignoreLonghands.includes(longhand)) continue; const shorthands = longhandToShorthands.get(longhand) || []; shorthands.push(shorthand); longhandToShorthands.set(longhand, shorthands); } } eachDeclarationBlock(root, (eachDecl) => { /** @type {Map<string, Declaration[]>} */ const longhandDeclarationNodes = new Map(); /** @type {Map<string, string>} */ const declaredShorthands = new Map(); eachDecl((decl) => { const declValue = decl.value.trim(); // basic keywords are not allowed in shorthand properties if (basicKeywords.has(declValue)) return; const prop = decl.prop.toLowerCase(); const unprefixedProp = vendor.unprefixed(prop); const prefix = vendor.prefix(prop); if ( longhandSubPropertiesOfShorthandProperties.has( /** @type {ShorthandProperties} */ (unprefixedProp), ) ) { declaredShorthands.set(prop, declValue); } const shorthandProperties = longhandToShorthands.get(unprefixedProp); if (!shorthandProperties) return; const { parent } = decl; assert(parent); for (const shorthandProperty of shorthandProperties) { const prefixedShorthandProperty = prefix + shorthandProperty; const shorthandValue = declaredShorthands.get(prefixedShorthandProperty); if (shorthandValue === declValue) { report({ ruleName, result, node: decl, word: decl.prop, message: messages.unexpectedLonghand, messageArgs: [decl.prop, prefixedShorthandProperty], fix: { apply: () => { decl.remove(); }, node: parent, }, }); return; } const declNodes = longhandDeclarationNodes.get(prefixedShorthandProperty) || []; declNodes.push(decl); longhandDeclarationNodes.set(prefixedShorthandProperty, declNodes); const prefixedShorthandData = [ ...(longhandSubPropertiesOfShorthandProperties.get(shorthandProperty) ?? []), ] .filter((item) => !ignoreLonghands.includes(item)) .map((item) => prefix + item); const collectedProps = declNodes.map((n) => n.prop.toLowerCase()); if (!arrayEqual(prefixedShorthandData.toSorted(), collectedProps.toSorted())) { continue; } if (hasMixedImportant(declNodes)) continue; const firstDeclNode = declNodes.at(0); const lastDeclNode = declNodes.at(-1); assert(firstDeclNode && lastDeclNode); const transformedDeclarationNodes = new Map( declNodes.map((d) => [d.prop.toLowerCase(), d]), ); const resolvedShorthandValue = resolveShorthandValue( prefixedShorthandProperty, prefixedShorthandData, transformedDeclarationNodes, ); const fix = resolvedShorthandValue ? () => { const newShorthandDeclarationNode = firstDeclNode.clone({ prop: prefixedShorthandProperty, value: resolvedShorthandValue, }); firstDeclNode.replaceWith(newShorthandDeclarationNode); declNodes.forEach((node) => node.remove()); if (OVERLAPPING_SHORTHANDS.has(shorthandProperty)) { const consumedProps = new Set(collectedProps); longhandDeclarationNodes.forEach((nodes, shorthand) => { longhandDeclarationNodes.set( shorthand, nodes.filter((node) => !consumedProps.has(node.prop.toLowerCase())), ); }); } } : undefined; const span = parent.index(lastDeclNode) - parent.index(firstDeclNode) + 1; const isContiguous = span === declNodes.length; const startDeclNode = isContiguous ? firstDeclNode : lastDeclNode; report({ ruleName, result, message: messages.expected, messageArgs: [prefixedShorthandProperty], node: parent, start: startDeclNode.rangeBy({}).start, end: lastDeclNode.rangeBy({}).end, fix: { apply: fix, node: parent, }, }); } }); }); }; }; /** * @param {string | undefined} input * @returns {string[]} */ function commaSeparated(input) { const trimmed = input?.trim(); if (!trimmed) return []; if (!trimmed.includes(',')) return [trimmed]; /** @type {ValueNode[][]} */ const parts = []; /** @type {ValueNode[]} */ let current = []; parts.push(current); for (const node of valueParser(trimmed).nodes) { if (isComma(node)) { current = []; parts.push(current); } else { current.push(node); } } return parts.map((nodes) => valueParser.stringify(nodes).trim()).filter(Boolean); } /** * @param {Declaration[]} decls * @returns {boolean} */ function hasMixedImportant(decls) { /** @type {(decl: Declaration) => boolean} */ const isImportant = (decl) => decl.important; return decls.some(isImportant) && !decls.every(isImportant); } /** * @param {string} prefixedShorthandProperty * @param {string[]} prefixedShorthandData * @param {Map<string, Declaration>} transformedDeclarationNodes * @returns {string | undefined} */ function resolveShorthandValue( prefixedShorthandProperty, prefixedShorthandData, transformedDeclarationNodes, ) { const resolver = customResolvers.get(prefixedShorthandProperty); if (resolver) return resolver(transformedDeclarationNodes); // TODO: Properly fix so that `background-size` appears immediately after // `background-position` and separated by a slash in the `background` shorthand. // Spec ref: https://drafts.csswg.org/css-backgrounds/#background // This temporary fix simply bails if `background-size` is present to avoid // autofixing to invalid CSS // PR ref: https://github.com/stylelint/stylelint/pull/9364/ if ( vendor.unprefixed(prefixedShorthandProperty) === 'background' && transformedDeclarationNodes.has(`${vendor.prefix(prefixedShorthandProperty)}background-size`) ) { return undefined; } // the "default" resolver: sort the longhand values in the order // of their properties return prefixedShorthandData .map((p) => transformedDeclarationNodes.get(p)?.value.trim() ?? '') .filter(Boolean) .join(' '); } rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; export default rule;