UNPKG

postcss-merge-longhand

Version:

Merge longhand properties into shorthand with PostCSS.

304 lines (278 loc) • 9.11 kB
import cssnanoUtils from 'cssnano-utils'; import { detach } from '../deferredChildEdits.js'; import insertCloned from '../insertCloned.js'; import { setsLonghands } from '../spec.js'; import { allBorderShorthands, borderImageProperties, borderPropertyToCellIndex, cellProperties, componentCells, sideCells, } from './borderData.js'; import { COMPONENT_SHORTHAND_CANDIDATE, RESET_CANDIDATE, SIDE_SHORTHAND_CANDIDATE, createGroupCandidate, createLeafCandidate, createResetCandidates, declSize, declarationsOfCells, getAvailableComponents, getAvailableSides, } from './borderCandidateBuilders.js'; const { asciiLowerCase } = cssnanoUtils; /** @import {Container, Declaration} from 'postcss'; */ /* Selection and application for the border reducer: given the candidates the * builders produce and the segment analysis state (final cell values, cell * history, barriers, fallbacks), score the non-overlapping covers and rewrite * the rule. Candidate construction lives in borderCandidateBuilders.js. */ /** @param {{prop: string, value: string}[]} candDecls @param {Set<number>} touched @param {boolean} hasReset */ function footprintValid(candDecls, touched, hasReset) { for (const { prop } of candDecls) { for (const p of setsLonghands(asciiLowerCase(prop))) { if (borderImageProperties.has(p) && !hasReset) return false; const cellIndex = borderPropertyToCellIndex.get(p); if (cellIndex !== undefined && !touched.has(cellIndex)) return false; } } return true; } /** * @template T * @param {T[]} items * @return {T[][]} */ function getSubsets(items) { const result = []; const n = items.length; const limit = Math.pow(2, n); for (let mask = 1; mask < limit; mask++) { const sub = []; for (let i = 0; i < n; i++) { if (Math.floor(mask / Math.pow(2, i)) % 2 === 1) sub.push(items[i]); } result.push(sub); } return result; } /** * @param {{ decls: { prop: string, value: string }[], rank: number, mask: number, resetIndex?: number }} a * @param {{ decls: { prop: string, value: string }[], rank: number, mask: number, resetIndex?: number }} b * @param {boolean} lane * @return {number} */ function compareCandidates(a, b, lane) { const sizeA = declSize(a.decls, lane); const sizeB = declSize(b.decls, lane); if (sizeA !== sizeB) return sizeA - sizeB; if (a.rank !== b.rank) return a.rank - b.rank; /* Broader coverage wins when the same shorthand family ties. */ if ( a.rank === COMPONENT_SHORTHAND_CANDIDATE || a.rank === SIDE_SHORTHAND_CANDIDATE ) { return b.mask - a.mask; } if (a.rank === RESET_CANDIDATE) { return (a.resetIndex ?? 0) - (b.resetIndex ?? 0); } return 0; } /** * @param {string[]} cells * @param {Set<Declaration>[]} cellHistory * @param {Set<number>} touched * @param {Set<number>} barrierCells * @param {boolean} hasReset * @param {boolean} lane * @param {Set<Declaration>} fallbacks */ export function generateCandidates( cells, cellHistory, touched, barrierCells, hasReset, lane, fallbacks ) { const availSides = getAvailableSides( cellHistory, touched, barrierCells, fallbacks ); const availComps = getAvailableComponents(cellHistory, touched, barrierCells); const protectedCells = new Set( [...touched].filter((idx) => [...cellHistory[idx]].some((d) => fallbacks.has(d)) ) ); const existingLeafCells = new Set( [...touched].filter((idx) => { return [...cellHistory[idx]].some( (d) => asciiLowerCase(d.prop) === cellProperties[idx] && asciiLowerCase(d.value) === asciiLowerCase(cells[idx]) ); }) ); const blockedCells = new Set([ ...barrierCells, ...protectedCells, ...existingLeafCells, ]); const resetCands = createResetCandidates( hasReset, touched, barrierCells, cellHistory, cells, lane, fallbacks ); const compCands = getSubsets(availComps) .map((sub) => createGroupCandidate( sub.map((c) => componentCells[c]), cells, touched, blockedCells, true ) ) .toSorted((a, b) => b.mask - a.mask); const sideCands = getSubsets(availSides) .map((sub) => createGroupCandidate( sub.map((s) => sideCells[s]), cells, touched, blockedCells, false ) ) .toSorted((a, b) => b.mask - a.mask); const leafCand = createLeafCandidate(cells, touched, blockedCells); const candidates = [...resetCands, ...compCands, ...sideCands, leafCand]; return candidates.filter((c) => footprintValid(c.decls, touched, hasReset)); } /** * @param {{ decls: { prop: string, value: string }[], rank: number, mask: number, resetIndex?: number, coveredCells: Set<number> }[]} candidates * @param {Set<Declaration>[]} cellHistory * @param {Set<Declaration>} fallbacks * @param {Set<number>} touched * @param {boolean} lane * @param {Declaration[]} segment * @return {{ cand: (typeof candidates)[0], repList: Declaration[], removable: Declaration[], diff: number, size: number, fallbacks: Set<Declaration> } | null} */ export function selectBestCandidate( candidates, cellHistory, fallbacks, touched, lane, segment ) { const allTouchedDecls = declarationsOfCells(cellHistory, touched); const allRemovable = [...allTouchedDecls].filter((d) => !fallbacks.has(d)); const originalSize = declSize(allRemovable, lane); /** @type {{ cand: (typeof candidates)[0], repList: Declaration[], removable: Declaration[], diff: number, size: number, fallbacks: Set<Declaration> } | null} */ let best = null; for (const cand of candidates) { if (cand.coveredCells.size === 0) continue; const candSize = declSize(cand.decls, lane); if (candSize > originalSize) continue; const rep = declarationsOfCells(cellHistory, cand.coveredCells); if (rep.size === 0) continue; const repList = segment.filter((d) => rep.has(d)); const removable = repList.filter((d) => !fallbacks.has(d)); const removableSet = new Set(removable); /* A declaration can contribute one cell to a candidate while still * controlling other cells. Removing it in that situation would silently * drop those values. Synthetic leaves normally complete the footprint; * barriers and preserved fallbacks deliberately prevent that completion. */ const removesOutsideFootprint = removable.some((d) => [...setsLonghands(asciiLowerCase(d.prop))].some((p) => { const cellIndex = borderPropertyToCellIndex.get(p); return cellIndex !== undefined && !cand.coveredCells.has(cellIndex); }) ); if (removesOutsideFootprint) continue; const remaining = allRemovable.filter((d) => !removableSet.has(d)); const size = candSize + declSize(remaining, lane); if (size > originalSize) continue; const diff = size - originalSize; const record = { cand, repList, removable, diff, size, fallbacks }; if (!best) { best = record; } else { const sizeDiff = size - best.size; const cmp = sizeDiff || compareCandidates(cand, best.cand, lane); if (cmp < 0) { best = record; } } } return best; } /** * @param {Container} rule * @param {{ cand: { decls: { prop: string, value: string }[], rank: number, mask: number, coveredCells: Set<number> }, repList: Declaration[], removable: Declaration[], diff: number, size: number, fallbacks: Set<Declaration> }} best * @param {Declaration[]} segment * @param {boolean} lane */ export function applyBestCandidate(rule, best, segment, lane) { const { cand: bestCand, repList, removable, diff: bestDiff, fallbacks, } = best; if (bestDiff === 0) { const shouldReplace = bestCand.decls.length < removable.length || bestCand.rank === RESET_CANDIDATE || (bestCand.rank === COMPONENT_SHORTHAND_CANDIDATE && bestCand.mask === 7); if (!shouldReplace) return; const isIdentical = bestCand.decls.length === repList.length && bestCand.decls.every( (d, i) => d.prop === repList[i].prop && d.value === repList[i].value ); if (isIdentical) return; } const representedDecls = new Set(repList); const anchor = repList.at(-1); if (!anchor) return; /** @type {{prop: string, value: string}[]} */ const toInsert = []; for (const candDecl of bestCand.decls) { const p = asciiLowerCase(candDecl.prop); if (allBorderShorthands.has(p)) { toInsert.push(candDecl); } else { const alreadyPresent = segment.find( (d) => !representedDecls.has(d) && asciiLowerCase(d.prop) === p && asciiLowerCase(d.value) === asciiLowerCase(candDecl.value) ); if (!alreadyPresent) { toInsert.push(candDecl); } } } let prev = anchor; for (const { prop, value } of toInsert) { prev = insertCloned(rule, prev, { prop, value, important: lane }); } for (const d of representedDecls) { if (!fallbacks.has(d)) { detach(d); } } }