stylelint
Version:
A mighty CSS linter that helps you avoid errors and enforce conventions.
418 lines (340 loc) • 12.1 kB
JavaScript
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;