postcss-merge-longhand
Version:
Merge longhand properties into shorthand with PostCSS.
305 lines (284 loc) • 9.91 kB
JavaScript
import cssnanoUtils from 'cssnano-utils';
import { isCssWideKeyword } from '../isCssWideKeyword.js';
import stylehacks from 'stylehacks';
import canExplode from '../canExplode.js';
import hasSubstitution from '../hasSubstitution.js';
import { mergeBlockingSupport, needsUnmetSupport } from '../isFallback.js';
import minifyTrbl from '../minifyTrbl.js';
import { minifyPair, parsePair } from '../pairs.js';
import parseTrbl from '../parseTrbl.js';
import { boxProperties, shorthandSlot } from './boxGroups.js';
import { isAll } from './importanceLanes.js';
import { SlotLane, commitShorthand, flushableSlots } from './slotVector.js';
const { asciiLowerCase } = cssnanoUtils;
/** @import {Container, Declaration} from 'postcss'; */
/** @import {BoxFamily} from './boxGroups.js'; */
/**
* A substitution function such as `var()` or `env()` may stand for any number
* of tokens, so a shorthand that holds one cannot be split into its slots.
* `hasSubstitution` also rejects a custom property, so `canExplode` skips it.
*
* @param {Declaration} declaration - a shorthand
* @return {boolean}
*/
export function isExplodable(declaration) {
return canExplode(declaration, false) && !hasSubstitution(declaration.value);
}
const trblForms = { parse: parseTrbl, minify: minifyTrbl };
const pairForms = { parse: parsePair, minify: minifyPair };
/**
* @param {BoxFamily} family
* @return {{ parse: (value: string) => string[], minify: (value: string | string[]) => string }}
* how the family spreads and condenses its slots: four sides, or a start and
* an end
*/
export function formsOf(family) {
return family.longhands.length === 4 ? trblForms : pairForms;
}
/**
* The values to put in the slots a later declaration overrides, so that the
* shorthand comes out as short as it can. Only the values of the other slots
* are candidates: they are valid for the group and add no requirement the
* shorthand did not have. Ties go to the earliest assignment found, trying
* the opposite slot's value first, so equal inputs give equal outputs.
*
* @param {string[]} values - one per slot
* @param {boolean[]} free - whether a later declaration overrides the slot
* @param {(value: string[]) => string} minify
* @return {string | undefined} the shortest value, if shorter than the
* original
*/
export function shortestWithFreeSlots(values, free, minify) {
const freeSlots = values
.keys()
.filter((slot) => free[slot])
.toArray();
const fixed = values
.keys()
.filter((slot) => !free[slot])
.toArray();
// A shorthand with every slot overridden is dead and goes in full.
if (freeSlots.length === 0 || fixed.length === 0) return undefined;
let best = minify(values);
// One token is the shortest a shorthand gets, and then every slot, the
// fixed ones included, already holds the same value.
if (!best.includes(' ')) return undefined;
let improved = false;
const trial = [...values];
// Equal values give equal results, so each distinct value is tried once, the
// opposite slot's first.
const candidates = freeSlots.map((slot) => {
const opposite = (slot + values.length / 2) % values.length;
/** @type {string[]} */
const distinct = free[opposite] ? [] : [values[opposite]];
for (const source of fixed) {
if (!distinct.includes(values[source])) distinct.push(values[source]);
}
return distinct;
});
/**
* @param {number} position
* @return {void}
*/
function assign(position) {
if (position === freeSlots.length) {
const candidate = minify(trial);
if (candidate.length < best.length) {
best = candidate;
improved = true;
}
return;
}
const slot = freeSlots[position];
for (const value of candidates[position]) {
trial[slot] = value;
assign(position + 1);
}
}
assign(0);
return improved ? best : undefined;
}
/**
* @param {BoxFamily} family
* @param {Declaration[]} laneDecls
* @return {number[]} for each slot, the position of the last declaration of
* the family that sets it and that every target parses, or `-1`
*/
function lastPlainSetters(family, laneDecls) {
const last = family.longhands.map(() => -1);
for (const [index, declaration] of laneDecls.entries()) {
const property = boxProperties.get(asciiLowerCase(declaration.prop));
if (
declaration.parent === undefined ||
property?.family !== family ||
stylehacks.detect(declaration) ||
needsUnmetSupport(declaration)
) {
continue;
}
if (property.slot === shorthandSlot) last.fill(index);
else last[property.slot] = index;
}
return last;
}
/**
* Rewrites the dead values of shorthands: a value a later declaration of the
* same family overrides changes nothing in the cascade whatever it says, and
* holds under every writing mode because both name the same physical side.
* That frees the slot to repeat a neighbour, which lets the shorthand
* condense, even where a declaration of the other kind keeps the later one
* from folding into it.
*
* Only a declaration that every target parses overrides, since a browser that
* drops it would otherwise fall back on the value that is rewritten.
*
* @param {BoxFamily} family
* @param {Declaration[]} laneDecls
* @param {Map<Declaration, Declaration> | undefined} inserted - the shorthand
* a commit put after a declaration
* @return {void}
*/
function freeOverriddenSlots(family, laneDecls, inserted) {
const { parse, minify } = formsOf(family);
/** @type {number[] | undefined} */
let lastOverrider;
for (let index = 0; index < laneDecls.length - 1; index++) {
const anchor = laneDecls[index];
const target = inserted?.get(anchor) ?? anchor;
if (target.parent === undefined) continue;
const property = boxProperties.get(asciiLowerCase(target.prop));
if (
property?.family !== family ||
property.slot !== shorthandSlot ||
stylehacks.detect(target) ||
!isExplodable(target) ||
mergeBlockingSupport(target).size > 0
) {
continue;
}
lastOverrider ??= lastPlainSetters(family, laneDecls);
if (lastOverrider.every((setter) => setter <= index)) continue;
const values = parse(target.value);
const result = shortestWithFreeSlots(
values,
lastOverrider.map((setter) => setter > index),
minify
);
if (result !== undefined && result.length < target.value.length) {
target.value = result;
delete target.raws.value;
}
}
}
/**
* @param {Container} rule
* @param {BoxFamily} family
* @param {({ value: string, decl: Declaration } | null)[]} slots
* @param {Set<Declaration>} contributing
* @param {Set<Declaration>} fallbacks
* @param {boolean} important
* @param {boolean} mayInsert - whether every target supports the shorthand
* @param {Map<Declaration, Declaration> | undefined} inserted
* @return {void}
*/
function flush(
rule,
family,
slots,
contributing,
fallbacks,
important,
mayInsert,
inserted
) {
const full = flushableSlots(slots);
if (!full) return;
const kw = isCssWideKeyword(full[0].value);
const rawValues = full.map((s) => s.value).join(' ');
const shorthandVal = kw ? full[0].value : formsOf(family).minify(rawValues);
commitShorthand(rule, full, contributing, fallbacks, {
prop: family.shorthand,
value: shorthandVal,
important,
mayInsert,
inserted,
});
}
/**
* Folds the declarations of one family in one importance lane into the
* shorthand, then frees the values later declarations override.
*
* Folding moves a value to the position of the shorthand, so it stops at any
* declaration that may set the same physical side: one of the other kind
* (physical or flow-relative) in the group, or one the plugin does not
* understand. The other axis of a flow-relative family never does. Creating
* or growing a shorthand also needs every target to support it.
*
* @param {Container} rule
* @param {BoxFamily} family
* @param {Declaration[]} laneDecls - the group's declarations of one
* importance, in source order
* @param {boolean} important
* @param {boolean} mayInsert - whether every target supports the shorthand
* @return {void}
*/
export function reduceFamilyLane(
rule,
family,
laneDecls,
important,
mayInsert
) {
const count = family.longhands.length;
/** @type {Map<Declaration, Declaration> | undefined} */
let inserted;
let position = 0;
const { parse } = formsOf(family);
const lane = new SlotLane(count, (slots, contributing, fallbacks) => {
// Only a shorthand with a declaration after it can have a value overridden.
if (position < laneDecls.length - 1) inserted ??= new Map();
flush(
rule,
family,
slots,
contributing,
fallbacks,
important,
mayInsert,
inserted
);
});
for (position = 0; position < laneDecls.length; position++) {
const decl = laneDecls[position];
const property = isAll(decl)
? undefined
: boxProperties.get(asciiLowerCase(decl.prop));
if (property === undefined) {
lane.reset();
continue;
}
if (property.family !== family) {
if (property.family.kind !== family.kind) lane.reset();
continue;
}
const isShort = property.slot === shorthandSlot;
if (stylehacks.detect(decl) || (isShort && !isExplodable(decl))) {
lane.reset();
continue;
}
const wasFull = lane.begin(
isShort ? 0 : property.slot,
isShort ? count : 1,
decl
);
const vals = isShort ? parse(decl.value) : null;
for (let i = 0; i < count; i++) {
if (isShort || i === property.slot) {
lane.assign(i, vals ? vals[i] : decl.value, decl, wasFull);
}
}
}
position = laneDecls.length;
lane.reset();
if (laneDecls.length > 1) freeOverriddenSlots(family, laneDecls, inserted);
}