postcss-merge-longhand
Version:
Merge longhand properties into shorthand with PostCSS.
292 lines (247 loc) • 7.16 kB
JavaScript
import { list } from 'postcss';
import valueParser from 'postcss-value-parser';
import stylehacks from 'stylehacks';
import canMerge from '../canMerge.js';
import getDecls from '../getDecls.js';
import getValue from '../getValue.js';
import mergeRules from '../mergeRules.js';
import insertCloned from '../insertCloned.js';
import { isFallback } from '../isFallback.js';
import canExplode from '../canExplode.js';
import lastOf from '../lastOf.js';
import { shorthand, initialValues } from '../spec.js';
const columns = 'columns';
/* The properties the shorthand sets */
const columnProperties = ['column-width', 'column-count'];
const columnPropertiesSet = new Set(columnProperties);
/* Column properties the shorthand does not set */
const otherColumnProperties = new Set(
shorthand(columns).longhands.filter(
(property) => !columnPropertiesSet.has(property)
)
);
const auto = /** @type {string} */ (initialValues.get(columnProperties[0]));
const inherit = 'inherit';
/* A unit is a bare identifier, so `30em/10em` is not a length. */
const lengthUnitRegex = /^[a-z]+$/i;
/**
* Normalize a columns shorthand definition. Both of the longhand
* properties' initial values are 'auto', and as per the spec,
* omitted values are set to their initial values. Thus, we can
* remove any 'auto' definition when there are two values.
*
* Specification link: https://www.w3.org/TR/css3-multicol/
*
* @param {[string, string]} values
* @return {string}
*/
function normalize(values) {
if (values[0].toLowerCase() === auto) {
return values[1];
}
if (values[1].toLowerCase() === auto) {
return values[0];
}
if (
values[0].toLowerCase() === inherit &&
values[1].toLowerCase() === inherit
) {
return inherit;
}
return values.join(' ');
}
/**
* The component a value can only have come from: `column-width` takes a
* length, `column-count` an integer, and `auto` fits either.
*
* @param {string} value
* @return {'width' | 'count' | 'initial' | undefined} undefined for anything
* else, since a value this cannot classify, `calc()` among them, could be
* either.
*/
function componentRole(value) {
if (value.toLowerCase() === auto) {
return 'initial';
}
const dimension = valueParser.unit(value);
if (!dimension) {
return undefined;
}
if (dimension.unit === '') {
return /^\d+$/.test(dimension.number) ? 'count' : undefined;
}
return lengthUnitRegex.test(dimension.unit) ? 'width' : undefined;
}
/**
* Takes the shorthand apart into the values it gives `column-width` and
* `column-count`, filling in the initial value for a component it leaves out.
* The two are combined with `||`, so they may appear in either order.
*
* https://drafts.csswg.org/css-multicol-2/#columns
*
* @param {string} value
* @return {[string, string] | undefined} undefined when the value is not a form
* that can be taken apart without guessing which component a value belongs to.
*/
function parseColumns(value) {
const values = list.space(value);
if (values.length > columnProperties.length) {
return undefined;
}
/** @type {(string | undefined)[]} */
const parsed = [undefined, undefined];
/** @type {string[]} */
const ambiguous = [];
for (const component of values) {
const role = componentRole(component);
if (role === undefined) {
return undefined;
}
if (role === 'initial') {
ambiguous.push(component);
continue;
}
const index = role === 'width' ? 0 : 1;
if (parsed[index] !== undefined) {
return undefined;
}
parsed[index] = component;
}
/* `auto` names whichever component the rest of the value does not. */
for (const component of ambiguous) {
const free = parsed.indexOf(undefined);
if (free === -1) {
return undefined;
}
parsed[free] = component;
}
return /** @type {[string, string]} */ (
parsed.map((component) => component ?? auto)
);
}
/**
* Check if a declaration sets column properties beyond `column-width` and
* `column-count`. The `columns: <width> / <height>` form sets others (like
* `column-height`), so we detect the slash. Only top-level slashes separate
* components; ones in functions like `calc(100%/3)` do not.
*
* @param {import('postcss').Declaration} declaration
* @return {boolean}
*/
function setsOtherColumnProperty(declaration) {
const prop = declaration.prop.toLowerCase();
if (otherColumnProperties.has(prop)) {
return true;
}
return (
prop === columns &&
valueParser(declaration.value).nodes.some(
(node) => node.type === 'div' && node.value === '/'
)
);
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function explode(rule) {
rule.walkDecls((decl) => {
if (decl.prop.toLowerCase() !== columns) {
return;
}
if (!canExplode(decl)) {
return;
}
if (stylehacks.detect(decl)) {
return;
}
const values = parseColumns(decl.value);
if (!values) {
return;
}
for (const [i, value] of values.entries()) {
insertCloned(/** @type {import('postcss').Rule} */ (decl.parent), decl, {
prop: columnProperties[i],
value,
});
}
decl.remove();
});
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function cleanup(rule) {
const decls = getDecls(rule, new Set([columns].concat(columnProperties)));
while (decls.size) {
const lastNode = lastOf(decls);
// remove properties of lower precedence
const lesser = [];
for (const node of decls) {
if (
!stylehacks.detect(lastNode) &&
!stylehacks.detect(node) &&
node !== lastNode &&
node.important === lastNode.important &&
lastNode.prop === columns &&
node.prop !== lastNode.prop &&
!isFallback(node, lastNode)
) {
lesser.push(node);
}
}
for (const node of lesser) {
node.remove();
decls.delete(node);
}
// get duplicate properties
const duplicates = [];
for (const node of decls) {
if (
!stylehacks.detect(lastNode) &&
!stylehacks.detect(node) &&
node !== lastNode &&
node.important === lastNode.important &&
node.prop === lastNode.prop &&
!isFallback(node, lastNode)
) {
duplicates.push(node);
}
}
for (const node of duplicates) {
node.remove();
decls.delete(node);
}
decls.delete(lastNode);
}
}
/**
* @param {import('postcss').Rule} rule
* @return {void}
*/
function merge(rule) {
mergeRules(rule, columnProperties, (rules, lastNode) => {
if (canMerge(rules) && !rules.some(stylehacks.detect)) {
insertCloned(
/** @type {import('postcss').Rule} */ (lastNode.parent),
lastNode,
{
prop: columns,
value: normalize(/** @type [string, string] */ (rules.map(getValue))),
}
);
for (const node of rules) {
node.remove();
}
return true;
}
return false;
});
cleanup(rule);
}
export default {
explode,
merge,
setsOtherColumnProperty,
};