UNPKG

postcss-merge-longhand

Version:

Merge longhand properties into shorthand with PostCSS.

290 lines (246 loc) 8.41 kB
import { list } from 'postcss'; import stylehacks from 'stylehacks'; import spec from '../spec.js'; import parseWsc from '../parseWsc.js'; import parseTrbl from '../parseTrbl.js'; import minifyWsc from '../minifyWsc.js'; import { isValidWidthStyleColor, specifiesComponent, specifiesDistinctComponents, } from '../validateWsc.js'; import isCustomProp from '../isCustomProp.js'; import { requiredSupport } from '../isFallback.js'; import canExplode from '../canExplode.js'; import cssGlobalKeywords from '../cssGlobalKeywords.js'; import insertCloned from '../insertCloned.js'; /** @import {Declaration, Rule} from 'postcss'; */ const sides = spec.sides; const components = spec.borderComponents; const sideShorthands = sides.map((side) => `border-${side}`); const componentShorthands = spec.shorthand('border').longhands; const sideComponents = sideShorthands.flatMap((side) => components.map((component) => `${side}-${component}`) ); const physicalProperties = new Set([ 'border', ...sideShorthands, ...componentShorthands, ...sideComponents, ]); const initialTriple = components.map( (component) => /** @type {string} */ (spec.initialValues.get(`border-${component}`)) ); /* `border` covers these without naming them, and the flow-relative properties * resolve against a writing mode this cannot see. */ const opaqueProperties = spec.flowRelativeBorderProperties.union( new Set(spec.shorthand('border').resets) ); /** * @param {Declaration} decl * @return {boolean} whether the declaration sets every part of every border, * so that nothing before it survives */ function establishesBorderReset(decl) { return ( decl.prop.toLowerCase() === 'border' && canExplode(decl) && !stylehacks.detect(decl) && specifiesDistinctComponents(decl.value) && isValidWidthStyleColor(parseWsc(decl.value)) ); } /** * Support can be required for two reasons, so both must be checked: a * `var()` value hides how many tokens it expands to, while a value using an * unsupported colour function can be identified but would cause browsers to * reject the entire shorthand if serialized. * * @param {Declaration} decl * @param {boolean} important the flag the rest of the border carries * @return {boolean} whether the declaration can be read into the grid and * written back out without losing anything */ function isResolvable(decl, important) { if ( decl.important !== important || stylehacks.detect(decl) || isCustomProp(decl) || cssGlobalKeywords.has(decl.value.toLowerCase()) || requiredSupport(decl).size ) { return false; } const prop = decl.prop.toLowerCase(); const tokens = list.space(decl.value); if (prop === 'border' || sideShorthands.includes(prop)) { return ( tokens.length <= components.length && specifiesDistinctComponents(decl.value) ); } /* A cell only holds what the property it came from can be set to: a value the * browser ignores, such as `border-color: none`, specifies no colour to read. */ const component = /** @type {string} */ (prop.split('-').at(-1)); if (componentShorthands.includes(prop)) { return ( tokens.length <= sides.length && parseTrbl(decl.value).every((value) => specifiesComponent(value, component) ) ); } return specifiesComponent(decl.value, component); } /* Precomputed byte cost of the `!important` flag for size calculations. */ const IMPORTANT = '!important'.length; /** * Two declaration sets being compared can differ in count (one side split * into components vs. one shorthand, or a resolved grid vs. its source * shorthands), so serialised length is the only valid comparison. * * @param {{prop: string, value: string}[]} declarations * @param {boolean} important whether each one carries `!important` * @return {number} what the declarations take up, `:` and `;` included */ function size(declarations, important) { let total = 0; for (const { prop, value } of declarations) { total += prop.length + value.length + 2 + (important ? IMPORTANT : 0); } return total; } /** * Reads the physical border declarations into a matrix of one width, style and * colour per side, in document order, so that each one overwrites the cells it * names. * * @param {Declaration[]} declarations from the reset onwards * @return {string[][]} a triple per side, in the order `spec.sides` gives */ function fillGrid(declarations) { const matrix = sides.map(() => [...initialTriple]); for (const decl of declarations) { const prop = decl.prop.toLowerCase(); const sideIndex = sideShorthands.indexOf( prop.split('-').slice(0, 2).join('-') ); const componentIndex = componentShorthands.indexOf( `border-${/** @type {string} */ (prop.split('-').at(-1))}` ); if (prop === 'border' || sideShorthands.includes(prop)) { const wsc = parseWsc(decl.value); const triple = initialTriple.map((initial, i) => wsc[i] || initial); for (const [i, cells] of matrix.entries()) { if (prop === 'border' || i === sideIndex) { cells.splice(0, cells.length, ...triple); } } } else if (componentShorthands.includes(prop)) { for (const [i, value] of parseTrbl(decl.value).entries()) { matrix[i][componentShorthands.indexOf(prop)] = value; } } else { matrix[sideIndex][componentIndex] = decl.value; } } return matrix; } /** * @param {string[][]} matrix * @return {string[]} the triple the most sides share, which `border` carries */ function commonTriple(matrix) { let winner = matrix[0]; let seen = 0; for (const triple of matrix) { const count = matrix.filter((other) => other.every((value, i) => value === triple[i]) ).length; if (count > seen) { seen = count; winner = triple; } } return winner; } /** * Writes the matrix back out as a `border` for the triple most sides share, and * the shortest serialization for each side that differs. * * @param {string[][]} matrix * @param {boolean} important whether the rule's declarations specify it * @return {{prop: string, value: string}[]} */ function serialise(matrix, important) { const base = commonTriple(matrix); const declarations = [{ prop: 'border', value: minifyWsc(base.join(' ')) }]; for (const [i, triple] of matrix.entries()) { const differing = triple .map((value, component) => ({ value, component })) .filter(({ value, component }) => value !== base[component]); if (!differing.length) { continue; } const asComponents = differing.map(({ value, component }) => ({ prop: `${sideShorthands[i]}-${components[component]}`, value, })); const asSide = [ { prop: sideShorthands[i], value: minifyWsc(triple.join(' ')) }, ]; declarations.push( ...(size(asComponents, important) < size(asSide, important) ? asComponents : asSide) ); } return declarations; } /** * This duplicates the main pipeline's intent for one case it can't otherwise * merge (a `border` reset followed by plain values), so it must stay guarded * by `containsUnmergeableBorderDecls` to avoid double-handling input. * * @param {Rule} rule * @return {void} */ function resolveBorderGrid(rule) { /** @type {Declaration[]} */ const physical = []; for (const node of rule.nodes) { if (node.type !== 'decl') { continue; } const prop = node.prop.toLowerCase(); /* The rule was handed over for one of several reasons, and this resolver * answers only the last of them. */ if (opaqueProperties.has(prop)) { return; } if (physicalProperties.has(prop)) { physical.push(node); } } const reset = physical.findIndex(establishesBorderReset); if (reset === -1) { return; } const important = physical[0].important; if (!physical.every((decl) => isResolvable(decl, important))) { return; } const resolved = serialise(fillGrid(physical.slice(reset)), important); if (size(resolved, important) >= size(physical, important)) { return; } let previous = physical[reset]; for (const { prop, value } of resolved) { previous = insertCloned(rule, previous, { prop, value, important }); } for (const decl of physical) { decl.remove(); } } export default resolveBorderGrid;