postcss-merge-longhand
Version:
Merge longhand properties into shorthand with PostCSS.
148 lines (128 loc) • 4.5 kB
JavaScript
import hasAllProps from './hasAllProps.js';
import getDeclarationsThatMatchProperties from './getDecls.js';
import getRules from './getRules.js';
import skipsFallback from './skipsFallback.js';
import lastOf from './lastOf.js';
import { setsLonghands } from './spec.js';
/**
* @param {import('postcss').Declaration} declA
* @param {import('postcss').Declaration} declB
* @return {boolean}
*/
function arePropertiesConflicting(declA, declB) {
if (!declA.prop || !declB.prop || declB.important !== declA.important) {
return false;
}
const partsA = declA.prop.split('-');
const partsB = declB.prop.split('-');
/* Be safe: check that the first part matches. So we don't try to
* combine e.g. border-color and color.
*/
if (partsA[0] !== partsB[0]) {
return false;
}
const partsASet = new Set(partsA);
const partsBSet = new Set(partsB);
return partsBSet.isSubsetOf(partsASet);
}
/**
* Whether two properties set any of the same longhands without either name
* containing the other — `border-top` fixes the side and leaves the component
* open, `border-color` does the opposite, and the two meet at
* `border-top-color` with neither name mentioning the other.
*
* `arePropertiesConflicting` cannot see these: it compares the name segments,
* and neither name is a subset of the other. Merging still moves one past the
* other, so the pair has to be weighed the same way as a property the merged
* one plainly contains.
*
* @param {import('postcss').Declaration} declA
* @param {import('postcss').Declaration} declB
* @return {boolean}
*/
function arePropertiesCrossing(declA, declB) {
/* `between` carries whatever sits in the rule, comments included, and a
* comment has no property to compare. */
if (!declA.prop || !declB.prop) {
return false;
}
const setsA = setsLonghands(declA.prop.toLowerCase());
const setsB = setsLonghands(declB.prop.toLowerCase());
return !setsA.isDisjointFrom(setsB);
}
/**
* @param {import('postcss').Declaration[]} match
* @param {import('postcss').Declaration[]} nodes
* @return {boolean}
*/
function hasConflicts(match, nodes) {
/** @type {number[]} */
const nodeIndices = match.map((n) => nodes.indexOf(n));
const firstNodeIndex = Math.min(...nodeIndices);
const lastNodeIndex = Math.max(...nodeIndices);
const matchSet = new Set(match);
const between = nodes
.slice(firstNodeIndex + 1, lastNodeIndex)
.filter((node) => !matchSet.has(node));
return match.some((a) =>
between.some((b) => {
/* Merging moves a to the end of the range, so anything in between that
* used to override it stops doing so.
*/
const overridesA = nodes.indexOf(b) > nodes.indexOf(a);
if (arePropertiesConflicting(a, b)) {
return a.prop !== b.prop || overridesA;
}
/* b names part of a, such as border-left-width against border-left, and
* only wins where it comes later.
*/
if (overridesA && a.prop !== b.prop && arePropertiesConflicting(b, a)) {
return true;
}
/* Neither name contains the other, yet the two reach some of the same
* longhands. Only the ones b currently overrides matter: a repeat that
* already came before a still comes before whatever replaces it.
*/
return overridesA && arePropertiesCrossing(a, b);
})
);
}
/**
* @param {import('postcss').Rule} rule
* @param {string[]} properties
* @param {(rules: import('postcss').Declaration[], last: import('postcss').Declaration, props: Set<import('postcss').Declaration>) => boolean} callback
* @return {void}
*/
function mergeRules(rule, properties, callback) {
const declarations = getDeclarationsThatMatchProperties(
rule,
new Set(properties)
);
while (declarations.size) {
const last = lastOf(declarations);
/** @type {Set<import('postcss').Declaration>} */
const props = new Set();
for (const node of declarations) {
if (node.important === last.important) {
props.add(node);
}
}
const rules = getRules(props, properties);
if (
hasAllProps(rules, ...properties) &&
!hasConflicts(
rules,
/** @type import('postcss').Declaration[]*/ (rule.nodes)
) &&
!skipsFallback(rules)
) {
if (callback(rules, last, props)) {
for (const node of rules) {
declarations.delete(node);
}
}
}
declarations.delete(last);
}
}
export default mergeRules;