postcss-merge-longhand
Version:
Merge longhand properties into shorthand with PostCSS.
1,272 lines (1,078 loc) • 34.5 kB
JavaScript
import { list } from 'postcss';
import stylehacks from 'stylehacks';
import insertCloned from '../insertCloned.js';
import parseTrbl from '../parseTrbl.js';
import getDecls from '../getDecls.js';
import getValue from '../getValue.js';
import mergeRules from '../mergeRules.js';
import minifyTopBottoRightLeft from '../minifyTrbl.js';
import minifyWidthStyleColor from '../minifyWsc.js';
import canMerge from '../canMerge.js';
import topRightBottomLeft from '../trbl.js';
import isCustomProp from '../isCustomProp.js';
import { isFallback, strandsFallback, inheritSupport } from '../isFallback.js';
import canExplode from '../canExplode.js';
import parseWidthStyleColor from '../parseWsc.js';
import {
isValidWidthStyleColor,
specifiesComponent,
specifiesDistinctComponents,
} from '../validateWsc.js';
import cssGlobalKeywords from '../cssGlobalKeywords.js';
import lastOf from '../lastOf.js';
import spec from '../spec.js';
import resolveBorderGrid from './borderMatrix.js';
import {
canMergeValues,
diffingProps,
getDistinctShorthands,
isCloseEnough,
isCustomProperty,
} from './borderPredicates.js';
/** @import {Declaration} from 'postcss'; */
const borderSpacing = 'border-spacing';
const widthStyleColor = spec.borderComponents;
/**
* @param {...string} parts
* @return {string}
*/
function borderProperty(...parts) {
return `border-${parts.join('-')}`;
}
const physicalBorderShorthands = spec.sides.map((side) => borderProperty(side));
const allSidesBorderShorthands = spec.shorthand('border').longhands;
/** @type {string[]} */
const physicalDirectionalProperties = [];
for (const direction of physicalBorderShorthands) {
for (const prop of widthStyleColor) {
physicalDirectionalProperties.push(`${direction}-${prop}`);
}
}
const defaultBorderValues = allSidesBorderShorthands.map(
(prop) => /** @type {string} */ (spec.initialValues.get(prop))
);
/* `border` and the shorthand for each side of the box. */
const borderAndSideShorthands = new Set([
'border',
...physicalBorderShorthands,
]);
/* Those, and the properties they are made of. */
const directionalPhysicalProperties = new Set([
...physicalBorderShorthands,
...physicalDirectionalProperties,
]);
/* What `border` resets without being able to set. */
const borderImageProperties = new Set(spec.shorthand('border').resets);
const precedence = [
['border'],
physicalBorderShorthands.concat(allSidesBorderShorthands),
physicalDirectionalProperties,
];
const allPhysicalBorderProperties = new Set(precedence.flat());
const borderResetRules = new WeakSet();
/**
* @param {string} prop
* @return {number | undefined}
*/
function getLevel(prop) {
for (let i = 0; i < precedence.length; i++) {
if (precedence[i].includes(prop.toLowerCase())) {
return i;
}
}
return undefined;
}
/**
* `insertCloned` records the support a new node inherits, but these
* merges place their node themselves; a clone postcss makes carries the value
* and not the provenance, so it has to be recorded here too.
*
* @param {Declaration} source
* @param {Partial<import('postcss').DeclarationProps>} props
* @return {Declaration}
*/
function cloneWithSupport(source, props) {
const clone = Object.assign(source.clone(), props);
inheritSupport(source, clone);
return clone;
}
/**
* @param {{values: [string, string, string], nextValues: [string, string, string], decl: import('postcss').Declaration, nextDecl: import('postcss').Declaration, index: number}} arg
* @return {void}
*/
function mergeRedundant({ values, nextValues, decl, nextDecl, index }) {
if (!canMerge([decl, nextDecl])) {
return;
}
if (stylehacks.detect(decl) || stylehacks.detect(nextDecl)) {
return;
}
const diff = diffingProps(values, nextValues, widthStyleColor);
if (diff.length !== 1) {
return;
}
const prop = /** @type {string} */ (diff.pop());
const position = widthStyleColor.indexOf(prop);
const prop1 = `${nextDecl.prop}-${prop}`;
const prop2 = `border-${prop}`;
const props = parseTrbl(values[position]);
props[index] = nextValues[position];
const borderValue2 = values.filter((e, i) => i !== position).join(' ');
const propValue2 = minifyTopBottoRightLeft(props);
const origLength = (
minifyWidthStyleColor(decl.value) +
nextDecl.prop +
nextDecl.value
).length;
const newLength1 =
decl.value.length +
prop1.length +
minifyWidthStyleColor(nextValues[position]).length;
const newLength2 = borderValue2.length + prop2.length + propValue2.length;
if (newLength1 < newLength2 && newLength1 < origLength) {
nextDecl.prop = prop1;
nextDecl.value = nextValues[position];
}
if (newLength2 < newLength1 && newLength2 < origLength) {
decl.value = borderValue2;
nextDecl.prop = prop2;
nextDecl.value = propValue2;
}
}
/**
* A border declaration specifies a `<line-width>`, a `<line-style>` and a
* `<color>` — one of them if it names a component, one of each per side
* otherwise — and the browser ignores it when a value is none of these,
* such as the `border-color: none` a stylesheet writes for `border: none`.
*
* @param {Declaration} declaration one of `allPhysicalBorderProperties`
* @return {boolean} whether the declaration sets anything at all
*/
function browserKeeps(declaration) {
const prop = declaration.prop.toLowerCase();
if (borderAndSideShorthands.has(prop)) {
return specifiesDistinctComponents(declaration.value);
}
const component = /** @type {string} */ (prop.split('-').at(-1));
if (!allSidesBorderShorthands.includes(prop)) {
return specifiesComponent(declaration.value, component);
}
/* `parseTrbl` takes the four sides it needs and says nothing about a fifth,
* which is a token that costs the declaration its meaning. */
if (list.space(declaration.value).length > spec.sides.length) {
return false;
}
return parseTrbl(declaration.value).every((value) =>
specifiesComponent(value, component)
);
}
/**
* @param {import('postcss').Rule} rule
* @return {boolean}
*/
function containsUnmergeableBorderDecls(rule) {
const declarations = /** @type {Declaration[]} */ (
rule.nodes.filter((node) => node.type === 'decl')
);
if (
declarations.some((declaration) => {
const prop = declaration.prop.toLowerCase();
return (
borderImageProperties.has(prop) ||
spec.flowRelativeBorderProperties.has(prop)
);
})
) {
return true;
}
const physical = declarations.filter((declaration) =>
allPhysicalBorderProperties.has(declaration.prop.toLowerCase())
);
if (
physical.some(
(declaration) =>
cssGlobalKeywords.has(declaration.value.toLowerCase()) ||
((borderAndSideShorthands.has(declaration.prop.toLowerCase()) ||
allSidesBorderShorthands.includes(declaration.prop.toLowerCase())) &&
isCustomProp(declaration))
)
) {
return true;
}
/* A declaration the browser ignores sets nothing, so the ones around it mean
* what they would mean on their own. Every transform here reads it as one
* that applies, and would move, ignore or rewrite those others against a
* border no side ever has. */
if (physical.some((declaration) => !browserKeeps(declaration))) {
return true;
}
const globalComponents = physical.filter((decl) =>
allSidesBorderShorthands.includes(decl.prop.toLowerCase())
);
const directionalDeclarations = physical.filter((decl) =>
directionalPhysicalProperties.has(decl.prop.toLowerCase())
);
return globalComponents.length > 1 && directionalDeclarations.length > 0;
}
/**
* @param {import('postcss').Node} node
* @return {boolean}
*/
function establishesBorderReset(node) {
if (node.type !== 'decl') {
return false;
}
const declaration = /** @type {Declaration} */ (node);
if (
declaration.prop.toLowerCase() !== 'border' ||
!canExplode(declaration) ||
stylehacks.detect(declaration)
) {
return false;
}
return (
specifiesDistinctComponents(declaration.value) &&
isValidWidthStyleColor(parseWidthStyleColor(declaration.value))
);
}
/**
* @param {import('postcss').Rule} rule
* @return {boolean}
*/
function hasBorderResetContext(rule) {
return borderResetRules.has(rule) || rule.nodes.some(establishesBorderReset);
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function mergeBorderSpacing(rule) {
rule.walkDecls((decl) => {
if (decl.prop.toLowerCase() !== borderSpacing) {
return;
}
const value = list.space(decl.value);
if (value.length > 1 && value[0] === value[1]) {
decl.value = value.slice(1).join(' ');
}
});
}
/**
* Removes duplicate declarations from a declaration list.
*
* @param {Set<import('postcss').Declaration>} declarations
* @param {import('postcss').Declaration | undefined} lastNode
* @returns {void}
*/
function removeDuplicateDeclarations(declarations, lastNode) {
/** @type {Set<Declaration>} */
const duplicateDeclarations = new Set();
for (const node of declarations) {
if (
!stylehacks.detect(/** @type {Declaration} */ (lastNode)) &&
!stylehacks.detect(node) &&
node !== lastNode &&
node.important === /** @type {Declaration} */ (lastNode).important &&
node.prop === /** @type {Declaration} */ (lastNode).prop &&
!isFallback(node, /** @type {Declaration} */ (lastNode))
) {
duplicateDeclarations.add(node);
}
}
for (const node of duplicateDeclarations) {
node.remove();
declarations.delete(node);
}
declarations.delete(/** @type {Declaration} */ (lastNode));
}
/**
* Removes lower precedence declarations from a declaration list.
*
* A shorthand only overrides the longhands before it in the browsers that keep
* it, so one requiring support the earlier browsers lack leaves an earlier
* longhand standing everywhere else. `strandsFallback` says which support
* requirements decide that, since a longhand the plugin exploded out of a
* shorthand answers for fewer of them than one the author wrote.
*
* @param {Set<import('postcss').Declaration>} decls
* @param {import('postcss').Declaration | undefined} lastNode
* @param {string | undefined} lastPart
* @returns {void}
*/
function removeLowerPrecedenceDeclarations(decls, lastNode, lastPart) {
const lesser = [];
for (const node of decls) {
if (
!stylehacks.detect(/** @type {Declaration} */ (lastNode)) &&
!stylehacks.detect(node) &&
!isCustomProp(/** @type {Declaration} */ (lastNode)) &&
node !== lastNode &&
!strandsFallback(node, /** @type {Declaration} */ (lastNode)) &&
node.important === /** @type {Declaration} */ (lastNode).important &&
/** @type {number} */ (getLevel(node.prop)) >
/** @type {number} */ (
getLevel(/** @type {Declaration} */ (lastNode).prop)
) &&
(node.prop
.toLowerCase()
.includes(/** @type {Declaration} */ (lastNode).prop) ||
node.prop.toLowerCase().endsWith(/** @type {string} */ (lastPart)))
) {
lesser.push(node);
}
}
for (const node of lesser) {
node.remove();
decls.delete(node);
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function cleanup(rule) {
rule.walkDecls((decl) => {
if (borderAndSideShorthands.has(decl.prop.toLowerCase())) {
decl.value = minifyWidthStyleColor(decl.value);
}
});
const decls = getDecls(rule, allPhysicalBorderProperties);
while (decls.size) {
const lastNode = lastOf(decls);
const lastPart = /** @type {Declaration} */ (lastNode).prop
.split('-')
.pop();
removeLowerPrecedenceDeclarations(decls, lastNode, lastPart);
removeDuplicateDeclarations(decls, lastNode);
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function explode(rule) {
if (rule.nodes.some(establishesBorderReset)) {
borderResetRules.add(rule);
}
if (containsUnmergeableBorderDecls(rule)) {
return;
}
rule.walkDecls((decl) => {
if (!spec.borderProperties.has(decl.prop.toLowerCase())) {
return;
}
if (!canExplode(decl)) {
return;
}
if (stylehacks.detect(decl)) {
return;
}
const prop = decl.prop.toLowerCase();
// border -> border-trbl
if (prop === 'border') {
if (isValidWidthStyleColor(parseWidthStyleColor(decl.value))) {
for (const direction of physicalBorderShorthands) {
insertCloned(
/** @type {import('postcss').Rule} */ (decl.parent),
decl,
{ prop: direction }
);
}
decl.remove();
}
}
// border-trbl -> border-trbl-wsc
if (physicalBorderShorthands.some((direction) => prop === direction)) {
const values = parseWidthStyleColor(decl.value);
if (isValidWidthStyleColor(values)) {
for (const [i, d] of widthStyleColor.entries()) {
insertCloned(
/** @type {import('postcss').Rule} */ (decl.parent),
decl,
{
prop: `${prop}-${d}`,
value: values[i] || defaultBorderValues[i],
}
);
}
decl.remove();
}
}
// border-wsc -> border-trbl-wsc
widthStyleColor.some((style) => {
if (prop !== borderProperty(style)) {
return false;
}
if (isCustomProp(decl)) {
decl.prop = decl.prop.toLowerCase();
return false;
}
for (const [i, value] of parseTrbl(decl.value).entries()) {
insertCloned(
/** @type {import('postcss').Rule} */ (decl.parent),
decl,
{
prop: borderProperty(topRightBottomLeft[i], style),
value,
}
);
}
return decl.remove();
});
});
}
/**
* `border`, `border-<side>` and `border-<component>` all reach a side's
* component without naming it, so the last declaration to set one side's
* component is not always the longhand that specifies it, and if
* they follow a longhand, they override the value set by the longhand.
*
* @param {import('postcss').ChildNode[]} nodes the nodes preceding the merge
* @param {string} side one of `topRightBottomLeft`
* @param {string} component one of `widthStyleColor`
* @return {Declaration | undefined} the longhand the side's component comes
* from, when a longhand is where it comes from
*/
function specifiedBy(nodes, side, component) {
const longhand = borderProperty(side, component);
const setters = new Set([
'border',
borderProperty(side),
borderProperty(component),
longhand,
]);
/** @type {Declaration | undefined} */
let last;
for (const node of nodes) {
const { type } = node;
if (type !== 'decl') {
continue;
}
if (setters.has(node.prop.toLowerCase())) {
last = node;
}
}
return last && last.prop.toLowerCase() === longhand ? last : undefined;
}
/**
* When a merge inserts at a range's start, later declarations for the same
* property remain. Those that are fallbacks or hacks must stay;
* others can be subsumed. Returns undefined if any repeat must be preserved.
*
* @param {import('postcss').Rule} rule
* @param {Declaration} start merge insertion point
* @param {Declaration[]} chosen declarations being merged, one per property
* @return {Declaration[] | undefined} repeats that can be safely removed,
* or undefined if any is a fallback or hack
*/
function subsumedAfter(rule, start, chosen) {
/** @type {Map<string, Declaration>} */
const wanted = new Map(chosen.map((node) => [node.prop.toLowerCase(), node]));
const from = rule.index(start);
/** @type {Declaration[]} */
const subsumed = [];
for (const node of rule.nodes) {
const { type } = node;
if (type !== 'decl') {
continue;
}
const last = wanted.get(node.prop.toLowerCase());
if (last === undefined) {
continue;
}
const at = rule.index(node);
if (at <= from || at > rule.index(last)) {
continue;
}
/* A repeat the browsers that skip the one it repeats still keep is a
* fallback the author wrote, not a leftover. */
if (node !== last && (isFallback(node, last) || stylehacks.detect(node))) {
return undefined;
}
subsumed.push(node);
}
return subsumed;
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function mergeSideComponentsToSide(rule) {
// border-trbl-wsc -> border-trbl
for (const direction of topRightBottomLeft) {
const prop = borderProperty(direction);
mergeRules(
rule,
widthStyleColor.map((style) => borderProperty(direction, style)),
(rules, lastNode) => {
const value = rules.map(getValue).join(' ');
/* One longhand the browser ignores costs that side its width, style or
* colour; written into the shorthand it costs the side every one of
* them, as the shorthand is then the invalid declaration. */
if (!specifiesDistinctComponents(value)) {
return false;
}
if (canMerge(rules, false) && !rules.some(stylehacks.detect)) {
insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode,
{
prop,
value,
}
);
for (const node of rules) {
node.remove();
}
return true;
}
return false;
}
);
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function mergeSideComponentsToComponent(rule) {
// border-trbl-wsc -> border-wsc
for (const style of widthStyleColor) {
const prop = borderProperty(style);
mergeRules(
rule,
topRightBottomLeft.map((direction) => borderProperty(direction, style)),
(rules, lastNode) => {
/* The four sides share one declaration afterwards, so a value the
* browser ignores on one side would take the other three down with it. */
if (!rules.every((node) => specifiesComponent(node.value, style))) {
return false;
}
if (canMerge(rules) && !rules.some(stylehacks.detect)) {
insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode,
{
prop,
value: minifyTopBottoRightLeft(rules.map(getValue).join(' ')),
}
);
for (const node of rules) {
node.remove();
}
return true;
}
return false;
}
);
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function mergeSidesToComponents(rule) {
// border-trbl -> border-wsc
mergeRules(rule, physicalBorderShorthands, (rules, lastNode) => {
if (rules.some(stylehacks.detect)) {
return false;
}
const values = rules.map(({ value }) => value);
if (!canMergeValues(values)) {
return false;
}
const parsed = values.map((value) => parseWidthStyleColor(value));
if (!parsed.every(isValidWidthStyleColor)) {
return false;
}
for (const [i, d] of widthStyleColor.entries()) {
const value = parsed.map((v) => v[i] || defaultBorderValues[i]);
if (canMergeValues(value)) {
insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode,
{
prop: borderProperty(d),
value: minifyTopBottoRightLeft(
/** @type {[string, string, string, string]} */ (value)
),
}
);
} else {
insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode
);
}
}
for (const node of rules) {
node.remove();
}
return true;
});
}
/**
* @param {import('postcss').Rule} rule
* @param {boolean} canCreateBorder
* @return {void}
*/
function mergeComponentsToBorder(rule, canCreateBorder) {
// border-wsc -> border
// border-wsc -> border + border-color
// border-wsc -> border + border-dir
mergeRules(rule, allSidesBorderShorthands, (rules, lastNode) => {
if (!canCreateBorder || rules.some(stylehacks.detect)) {
return false;
}
const values = rules.map((node) => parseTrbl(node.value));
const mapped = [0, 1, 2, 3].map((i) =>
[values[0][i], values[1][i], values[2][i]].join(' ')
);
if (!canMergeValues(mapped)) {
return false;
}
const [width, style, color] = rules;
const reduced = getDistinctShorthands(mapped);
if (isCloseEnough(mapped) && canMerge(rules, false)) {
const first =
mapped.indexOf(reduced[0]) !== mapped.lastIndexOf(reduced[0]);
const border = insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode,
{
prop: 'border',
value: first ? reduced[0] : reduced[1],
}
);
if (reduced[1]) {
const value = first ? reduced[1] : reduced[0];
const prop = borderProperty(topRightBottomLeft[mapped.indexOf(value)]);
rule.insertAfter(
border,
cloneWithSupport(lastNode, {
prop,
value,
})
);
}
for (const node of rules) {
node.remove();
}
return true;
} else if (reduced.length === 1 && canMerge([width, style], false)) {
rule.insertBefore(
color,
cloneWithSupport(lastNode, {
prop: 'border',
value: [width, style].map(getValue).join(' '),
})
);
for (const node of rules) {
if (node.prop.toLowerCase() !== allSidesBorderShorthands[2]) {
node.remove();
}
}
return true;
}
return false;
});
}
/**
* @param {import('postcss').Rule} rule
* @param {boolean} canCreateBorder
* @return {void}
*/
function mergeComponentsToBorderAndSides(rule, canCreateBorder) {
// border-wsc -> border + border-trbl
mergeRules(rule, allSidesBorderShorthands, (rules, lastNode) => {
if (!canCreateBorder || rules.some(stylehacks.detect)) {
return false;
}
const values = rules.map((node) => parseTrbl(node.value));
const mapped = [0, 1, 2, 3].map((i) =>
[values[0][i], values[1][i], values[2][i]].join(' ')
);
const reduced = getDistinctShorthands(mapped);
const none = 'medium none currentcolor';
if (reduced.length > 1 && reduced.length < 4 && reduced.includes(none)) {
const filtered = mapped.find((p) => p !== none);
const mostCommon = reduced.toSorted(
(a, b) =>
mapped.filter((v) => v === b).length -
mapped.filter((v) => v === a).length
)[0];
const borderValue = reduced.length === 2 ? filtered : mostCommon;
rule.insertBefore(
lastNode,
cloneWithSupport(lastNode, {
prop: 'border',
value: borderValue,
})
);
for (const [i, dir] of physicalBorderShorthands.entries()) {
if (mapped[i] !== borderValue) {
rule.insertBefore(
lastNode,
cloneWithSupport(lastNode, {
prop: dir,
value: mapped[i],
})
);
}
}
for (const node of rules) {
node.remove();
}
return true;
}
return false;
});
}
/**
* @param {import('postcss').Rule} rule
* @param {boolean} canCreateBorder
* @return {void}
*/
function mergeSidesToBorder(rule, canCreateBorder) {
// border-trbl -> border
// border-trbl -> border + border-trbl
mergeRules(rule, physicalBorderShorthands, (rules, lastNode) => {
if (!canCreateBorder || rules.some(stylehacks.detect)) {
return false;
}
const values = rules.map((node) => {
const wscValue = parseWidthStyleColor(node.value);
if (!isValidWidthStyleColor(wscValue)) {
return node.value;
}
return wscValue
.map((value, i) => value || defaultBorderValues[i])
.join(' ');
});
const reduced = getDistinctShorthands(values);
if (isCloseEnough(values)) {
const first =
values.indexOf(reduced[0]) !== values.lastIndexOf(reduced[0]);
rule.insertBefore(
lastNode,
cloneWithSupport(lastNode, {
prop: 'border',
value: minifyWidthStyleColor(first ? values[0] : values[1]),
})
);
if (reduced[1]) {
const value = first ? reduced[1] : reduced[0];
const prop = physicalBorderShorthands[values.indexOf(value)];
rule.insertBefore(
lastNode,
cloneWithSupport(lastNode, {
prop: prop,
value: minifyWidthStyleColor(value),
})
);
}
for (const node of rules) {
node.remove();
}
return true;
}
return false;
});
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function rebindSideCustomProp(rule) {
// border-trbl-wsc + border-trbl (custom prop) -> border-trbl + border-trbl-wsc (custom prop)
for (const direction of physicalBorderShorthands) {
for (const [i, style] of widthStyleColor.entries()) {
const prop = `${direction}-${style}`;
mergeRules(rule, [direction, prop], (rules, lastNode) => {
if (lastNode.prop !== direction) {
return false;
}
const values = parseWidthStyleColor(lastNode.value);
if (!isValidWidthStyleColor(values)) {
return false;
}
const wscProp = rules.find((r) => r !== lastNode);
if (!wscProp) {
return false;
}
if (!isCustomProperty(values[i]) || isCustomProp(wscProp)) {
return false;
}
const wscValue = values[i];
values[i] = wscProp.value;
if (canMerge(rules, false) && !rules.some(stylehacks.detect)) {
insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode,
{
prop,
value: wscValue,
}
);
lastNode.value = minifyWidthStyleColor(/** @type {any} */ (values));
wscProp.remove();
return true;
}
return false;
});
}
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function rebindComponentCustomProp(rule) {
// border-wsc + border (custom prop) -> border + border-wsc (custom prop)
for (const [i, style] of widthStyleColor.entries()) {
const prop = borderProperty(style);
mergeRules(rule, ['border', prop], (rules, lastNode) => {
if (lastNode.prop !== 'border') {
return false;
}
const values = parseWidthStyleColor(lastNode.value);
if (!isValidWidthStyleColor(values)) {
return false;
}
const wscProp = rules.find((r) => r !== lastNode);
if (!wscProp) {
return false;
}
if (!isCustomProperty(values[i]) || isCustomProp(wscProp)) {
return false;
}
const wscValue = values[i];
values[i] = wscProp.value;
if (canMerge(rules, false) && !rules.some(stylehacks.detect)) {
insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode,
{
prop,
value: wscValue,
}
);
lastNode.value = minifyWidthStyleColor(/** @type {any} */ (values));
wscProp.remove();
return true;
}
return false;
});
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function optimizeSides(rule) {
// optimize border-trbl
const decls = getDecls(rule, new Set(physicalBorderShorthands));
while (decls.size) {
const lastNode = /** @type {Declaration} */ (lastOf(decls));
const lastSide = physicalBorderShorthands.indexOf(
lastNode.prop.toLowerCase()
);
if (
specifiesDistinctComponents(lastNode.value) &&
!stylehacks.detect(lastNode)
) {
const lastValues = parseWidthStyleColor(lastNode.value);
/* `parseWsc` lower-cases what it hands back, and the longhands beside it
* keep the case the stylesheet wrote, so the token itself is what the
* two get compared and merged as. */
const tokens = list.space(lastNode.value);
/** @type {(component: string) => string} */
const asWritten = (component) =>
tokens.find((token) => token.toLowerCase() === component) ?? component;
for (const [i, d] of widthStyleColor.entries()) {
const nodes = rule.nodes.slice(0, rule.nodes.indexOf(lastNode));
const specifiers = topRightBottomLeft.map((side, index) =>
index === lastSide ? undefined : specifiedBy(nodes, side, d)
);
const longhands = /** @type {Declaration[]} */ (
specifiers.filter(Boolean)
);
if (
longhands.length !== topRightBottomLeft.length - 1 ||
longhands.some(
(node) =>
node.important !== lastNode.important || stylehacks.detect(node)
)
) {
continue;
}
/* The three longhands come together where the last of them stands. */
let refNode = longhands[0];
for (const node of longhands) {
if (rule.index(node) > rule.index(refNode)) {
refNode = node;
}
}
/* The shorthand specifies a component or resets it to its initial
* value; either way that is what the side ends up with. */
const lastNodeValue = lastValues[i]
? asWritten(lastValues[i])
: defaultBorderValues[i];
const values = specifiers.map((node, index) =>
index === lastSide
? lastNodeValue
: /** @type {Declaration} */ (node).value
);
const value = minifyTopBottoRightLeft(values.join(' '));
if (value === lastNodeValue) {
const remaining = tokens
.filter((token) => token.toLowerCase() !== lastValues[i])
.join(' ');
/* Dropping the component from the shorthand leaves the shorthand
* resetting it, so what replaces it has to come after. */
if (remaining && remaining.length < lastNode.value.length) {
lastNode.value = remaining;
refNode = lastNode;
}
}
insertCloned(
/** @type {import('postcss').Rule} */ (refNode.parent),
refNode,
{
prop: borderProperty(d),
value,
}
);
for (const node of longhands) {
node.remove();
}
}
}
decls.delete(lastNode);
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function mergeRedundantSweep(rule) {
rule.walkDecls(
'border',
(/** @type {import('postcss').Declaration} */ decl) => {
const nextDecl = decl.next();
if (!nextDecl || nextDecl.type !== 'decl') {
return false;
}
const index = physicalBorderShorthands.indexOf(nextDecl.prop);
if (index === -1) {
return;
}
const values = parseWidthStyleColor(decl.value);
const nextValues = parseWidthStyleColor(nextDecl.value);
if (
!isValidWidthStyleColor(values) ||
!isValidWidthStyleColor(nextValues)
) {
return;
}
const config = {
values,
nextValues,
decl,
nextDecl,
index,
};
return mergeRedundant(config);
}
);
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function hoistSubsumedComponents(rule) {
rule.walkDecls((/** @type {import('postcss').Declaration} */ decl) => {
if (!borderAndSideShorthands.has(decl.prop.toLowerCase())) {
return;
}
const values = parseWidthStyleColor(decl.value);
if (!isValidWidthStyleColor(values)) {
return;
}
const position = physicalBorderShorthands.indexOf(decl.prop);
const dirs = [...physicalBorderShorthands];
dirs.splice(position, 1);
for (const [i, d] of widthStyleColor.entries()) {
const props = dirs.map((dir) => `${dir}-${d}`);
mergeRules(rule, [decl.prop, ...props], (rules) => {
if (!rules.includes(decl)) {
return false;
}
const longhands = rules.filter((p) => p !== decl);
const subsumed = subsumedAfter(rule, decl, longhands);
if (subsumed === undefined) {
return false;
}
if (
longhands[0].value.toLowerCase() ===
longhands[1].value.toLowerCase() &&
longhands[1].value.toLowerCase() ===
longhands[2].value.toLowerCase() &&
values[i] !== undefined &&
longhands[0].value.toLowerCase() === values[i].toLowerCase()
) {
for (const node of subsumed) {
node.remove();
}
insertCloned(
/** @type {import('postcss').Rule} */ (decl.parent),
decl,
{
prop: borderProperty(d),
value: values[i],
}
);
/* The same slot is offered again for the other sides, and a
* component hoisted out of the shorthand is no longer one it
* specifies — which is what an absent component already reads as. */
/** @type {string|undefined} */ (values[i]) = undefined;
}
return false;
});
const newValue = values.join(' ');
if (newValue) {
decl.value = newValue;
} else {
decl.remove();
}
}
});
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function merge(rule) {
mergeBorderSpacing(rule);
if (containsUnmergeableBorderDecls(rule)) {
resolveBorderGrid(rule);
return;
}
const canCreateBorder = hasBorderResetContext(rule);
mergeSideComponentsToSide(rule);
mergeSideComponentsToComponent(rule);
mergeSidesToComponents(rule);
mergeComponentsToBorder(rule, canCreateBorder);
mergeComponentsToBorderAndSides(rule, canCreateBorder);
mergeSidesToBorder(rule, canCreateBorder);
rebindSideCustomProp(rule);
rebindComponentCustomProp(rule);
optimizeSides(rule);
mergeRedundantSweep(rule);
hoistSubsumedComponents(rule);
cleanup(rule);
}
export default {
explode,
merge,
};