postcss-merge-longhand
Version:
Merge longhand properties into shorthand with PostCSS.
339 lines (316 loc) • 10.5 kB
JavaScript
import { expandFourSides, fourSideCount } from './parseTrbl.js';
import { cssWideKeywords } from './spec.js';
import cssnanoUtils from 'cssnano-utils';
import shorthandData from '../data/shorthandIdentities.json' with { type: 'json' };
import { shorthandForms } from './decl/alignmentForms.js';
import { splitValue } from './valueComponents.js';
import {
componentKey,
componentName,
directNumeric,
hasAllowedFunctions,
hasCssWideKeyword,
isLengthComponent,
tokenName,
} from './shorthandValueGrammar.js';
const { TokenType, asciiLowerCase, numeric, tokenEnd, tokenStart } =
cssnanoUtils;
const overflowKeywords = new Set([
'visible',
'hidden',
'clip',
'scroll',
'auto',
]);
const overscrollKeywords = new Set(['auto', 'contain', 'none']);
const axisKeywords = new Map([
['overflow', overflowKeywords],
['overscroll-behavior', overscrollKeywords],
]);
const gapKeywords = new Set(['normal']);
const timingKeywords = new Set(shorthandData.easing.keywords);
const timingFunctions = new Set(shorthandData.easing.functions);
const transitionTimeUnits = new Set(['ms', 's']);
/**
* @typedef {{raw: string, tokens: import('@csstools/css-tokenizer').CSSToken[]}}
* Component
*/
/** @param {Component[]} components @return {string | null} */
function reduceTwoAxis(components) {
if (components.length !== 2) return null;
if (componentKey(components[0]) !== componentKey(components[1])) return null;
return components[0].raw;
}
/**
* @param {string} property
* @param {Component[]} components
* @return {string | null}
*/
function reduceTwoAxisProperty(property, components) {
if (property === 'gap') {
if (
!components.every(
(component) =>
(component.tokens.length === 1 &&
component.tokens[0][0] === TokenType.Ident &&
gapKeywords.has(tokenName(component.tokens[0]))) ||
isLengthComponent(component, {
percentage: true,
auto: false,
nonNegative: true,
})
)
)
return null;
} else if (axisKeywords.has(property)) {
const keywords = /** @type {Set<string>} */ (axisKeywords.get(property));
if (
!components.every(
(component) =>
component.tokens.length === 1 &&
component.tokens[0][0] === TokenType.Ident &&
keywords.has(tokenName(component.tokens[0]))
)
)
return null;
} else if (property === 'border-spacing') {
if (
!components.every((component) =>
isLengthComponent(component, {
percentage: false,
auto: false,
nonNegative: true,
})
)
)
return null;
} else {
return null;
}
return reduceTwoAxis(components);
}
/** @param {string} property @param {Component[]} components @return {string | null} */
function reduceFourSide(property, components) {
if (components.length < 1 || components.length > 4) return null;
const grammar = fourSideGrammar(property);
if (!components.every((component) => isLengthComponent(component, grammar))) {
return null;
}
const values = expandFourSides(components);
const count = fourSideCount(values.map(componentKey));
return count === 4
? null
: values
.slice(0, count)
.map(({ raw }) => raw)
.join(' ');
}
/** @param {string} property @return {{percentage: boolean, auto: boolean, nonNegative?: boolean}} */
function fourSideGrammar(property) {
if (property === 'inset') return { percentage: true, auto: true };
if (property === 'scroll-margin') return { percentage: false, auto: false };
return { percentage: true, auto: true, nonNegative: true };
}
/** @param {string} property @param {Component[]} components @return {string | null} */
function reduceAlignment(property, components) {
const forms = shorthandForms.get(property);
if (!forms || components.some((component) => !isSingleIdent(component))) {
return null;
}
for (let split = 1; split < components.length; split++) {
const first = components.slice(0, split);
const second = components.slice(split);
const firstName = first.map(componentName).join(' ');
const secondName = second.map(componentName).join(' ');
if (
firstName === secondName &&
forms.has(firstName) &&
first.map(componentKey).join('|') === second.map(componentKey).join('|')
) {
return first.map(({ raw }) => raw).join(' ');
}
}
return null;
}
/** @param {Component} component @return {boolean} */
function isSingleIdent(component) {
return (
component.tokens.length === 1 &&
component.tokens[0][0] === TokenType.Ident &&
!cssWideKeywords.has(componentName(component))
);
}
/** @param {string} value @return {string | null} */
function reduceAspectRatio(value) {
const parsed = splitValue(value);
if (!parsed) return null;
const input = parsed.flatMap((part) =>
part.components.flatMap((component) => component.tokens)
);
const significant = input.filter(
(token) => token[0] !== TokenType.Whitespace
);
const offset =
significant[0]?.[0] === TokenType.Ident &&
tokenName(significant[0]) === 'auto'
? 1
: 0;
if (
significant.length !== offset + 3 ||
significant[offset]?.[0] !== TokenType.Number ||
significant[offset + 1]?.[0] !== TokenType.Delim ||
significant[offset + 1][1] !== '/' ||
significant[offset + 2]?.[0] !== TokenType.Number
)
return null;
const numerator = numeric(significant[offset]);
const denominator = numeric(significant[offset + 2]);
if (
!numerator ||
!denominator ||
numerator.number <= 0 ||
denominator.number !== 1 ||
!Number.isFinite(numerator.number) ||
!Number.isFinite(denominator.number)
)
return null;
const rawNumerator = value.slice(
tokenStart(significant[offset]),
tokenEnd(significant[offset])
);
if (offset)
return `${value.slice(tokenStart(significant[0]), tokenEnd(significant[0]))} ${rawNumerator}`;
return rawNumerator;
}
/** @param {Component} component @return {{number: number, unit: string} | null} */
function transitionTime(component) {
const value = directNumeric(component);
return value &&
transitionTimeUnits.has(asciiLowerCase(value.unit)) &&
Number.isFinite(value.number)
? value
: null;
}
/** @param {Component | null | undefined} component @return {boolean} */
function isZeroTransitionTime(component) {
if (!component) return false;
const value = transitionTime(component);
return Boolean(value && value.number === 0);
}
/** @param {Component} component @return {boolean} */
function isTimingFunction(component) {
const token = component.tokens[0];
if (component.tokens.length === 1 && token[0] === TokenType.Ident) {
return timingKeywords.has(tokenName(token));
}
return (
token[0] === TokenType.Function &&
timingFunctions.has(tokenName(token)) &&
hasAllowedFunctions(component, timingFunctions)
);
}
/** @param {Component} component @return {boolean} */
function isTransitionProperty(component) {
if (hasCssWideKeyword(component) || component.tokens.length !== 1)
return false;
return component.tokens[0][0] === TokenType.Ident;
}
/** @typedef {{property: Component | null, duration: Component | null, timingFunction: Component | null, delay: Component | null}} TransitionSlots */
/** @param {{components: Component[], raw: string}} part @return {string | false | null} */
function reduceTransitionPart(part) {
/** @type {TransitionSlots} */
let slots = {
property: null,
duration: null,
timingFunction: null,
delay: null,
};
for (const component of part.components) {
const consumed = consumeTransitionComponent(component, slots);
if (!consumed) return null;
slots = consumed;
}
if (!slots.property) return null;
const removeDelay = isZeroTransitionTime(slots.delay);
const removeTiming =
slots.timingFunction !== null &&
componentName(slots.timingFunction) === 'ease';
const removeDuration =
isZeroTransitionTime(slots.duration) && (!slots.delay || removeDelay);
if (!removeDuration && !removeTiming && !removeDelay) return false;
const removed = new Set([
removeDuration ? slots.duration : null,
removeTiming ? slots.timingFunction : null,
removeDelay ? slots.delay : null,
]);
return part.components
.filter((component) => !removed.has(component))
.map(({ raw }) => raw)
.join(' ');
}
/**
* @param {Component} component
* @param {TransitionSlots} state
* @return {TransitionSlots | null}
*/
function consumeTransitionComponent(component, state) {
const time = transitionTime(component);
if (time) {
if (!state.duration) {
if (time.number < 0) return null;
return { ...state, duration: component };
}
if (state.delay) return null;
return { ...state, delay: component };
}
if (isTimingFunction(component)) {
if (state.timingFunction) return null;
return { ...state, timingFunction: component };
}
if (isTransitionProperty(component) && !state.property) {
return { ...state, property: component };
}
return null;
}
/** @param {string} value @return {string | null} */
function reduceTransition(value) {
const parsed = splitValue(value, ',');
if (!parsed) return null;
const reduced = parsed.map(reduceTransitionPart);
if (reduced.some((part) => part === null)) return null;
if (reduced.every((part) => part === false)) return null;
return reduced.map((part, index) => part || parsed[index].raw).join(',');
}
/** @param {string} property @param {string} value @return {string | null} */
export function normalizeValue(property, value) {
if (property === 'aspect-ratio') return reduceAspectRatio(value);
if (property === 'transition' || property === '-webkit-transition') {
return reduceTransition(value);
}
const parsed = splitValue(value);
if (!parsed || parsed.length !== 1) return null;
const components = parsed[0].components;
if (
property === 'border-spacing' ||
property === 'gap' ||
property === 'overflow' ||
property === 'overscroll-behavior'
) {
return reduceTwoAxisProperty(property, components);
}
if (
property === 'inset' ||
property === 'scroll-margin' ||
property === 'scroll-padding'
) {
return reduceFourSide(property, components);
}
if (
property === 'place-items' ||
property === 'place-self' ||
property === 'place-content'
) {
return reduceAlignment(property, components);
}
return null;
}