postcss-merge-longhand
Version:
Merge longhand properties into shorthand with PostCSS.
289 lines (260 loc) • 9.01 kB
JavaScript
import cssnanoUtils from 'cssnano-utils';
import stylehacks from 'stylehacks';
import { shorthand, initialValues, cssWideKeywords } from '../spec.js';
import { isLengthValue } from '../lengthGrammar.js';
import { isUnresolved } from '../unresolved.js';
import { closingTokens } from '../valueComponents.js';
/** @import {Declaration} from 'postcss'; */
const { TokenType, asciiLowerCase, closeForOpening, tokens } = cssnanoUtils;
export const columns = 'columns';
const columnProperties = ['column-width', 'column-count'];
/** Slot of each longhand in the parsed `columns` value. */
export const columnSlots = new Map(
columnProperties.map((name, slot) => [name, slot])
);
export const allColumnProps = new Set([columns, ...columnProperties]);
const otherColumnProperties = new Set(
shorthand(columns).longhands.filter((p) => !allColumnProps.has(p))
);
const auto = /** @type {string} */ (initialValues.get(columnProperties[0]));
/**
* @param {string} value
* @return {{ value: string, hasTopLevelSlash: boolean, terms: { start: number, end: number, tokenCount: number, type: import('@csstools/css-tokenizer').TokenType, decoded: unknown }[] }}
*/
function tokenizeColumns(value) {
/** @type {{ start: number, end: number, tokenCount: number, type: import('@csstools/css-tokenizer').TokenType, decoded: unknown }[]} */
const terms = [];
let start = -1,
end = -1,
tokenCount = 0,
depth = 0;
let type = TokenType.EOF;
/** @type {unknown} */
let decoded;
let hasTopLevelSlash = false;
const push = () => {
if (tokenCount) {
terms.push({ start, end, tokenCount, type, decoded });
start = -1;
tokenCount = 0;
decoded = undefined;
}
};
for (const token of tokens(value)) {
const tokenType = token[0];
if (tokenType === TokenType.EOF) continue;
if (depth === 0 && tokenType === TokenType.Whitespace) {
push();
continue;
}
if (tokenCount === 0) {
start = token[2];
type = /** @type {import('@csstools/css-tokenizer').TokenType} */ (
tokenType
);
decoded = token[4];
}
if (depth === 0 && tokenType === TokenType.Delim && token[1] === '/') {
hasTopLevelSlash = true;
}
tokenCount++;
end = token[3] + 1;
if (closeForOpening(tokenType) !== undefined) {
depth++;
} else if (depth > 0 && closingTokens.has(tokenType)) {
depth--;
}
}
push();
return { value, hasTopLevelSlash, terms };
}
/** @type {WeakMap<Declaration, { value: string, parsed: ReturnType<typeof tokenizeColumns> }>} */
const parsedDeclarations = new WeakMap();
/** @param {Declaration} d @return {ReturnType<typeof tokenizeColumns>} */
export function parsedValue(d) {
let cached = parsedDeclarations.get(d);
if (!cached || cached.value !== d.value) {
cached = { value: d.value, parsed: tokenizeColumns(d.value) };
parsedDeclarations.set(d, cached);
}
return cached.parsed;
}
/**
* Normalize a columns shorthand definition. Both longhand initial values
* are 'auto', and omitted values reset to initial, so 'auto' can be dropped.
*
* Specification links: https://www.w3.org/TR/css-multicol-2/#columns
* and https://www.w3.org/TR/css-sizing-4/#column-sizing
*
* @param {[string, string]} values
* @return {string}
*/
export function normalize([w, c]) {
const lw = asciiLowerCase(w);
const lc = asciiLowerCase(c);
if (lw === auto) return c;
if (lc === auto) return w;
return lw === lc && cssWideKeywords.has(lw) ? lw : `${w} ${c}`;
}
/**
* A `<length [0,∞]>`: CSS Sizing 4 allows a zero `column-width`.
*
* @param {ReturnType<typeof tokenizeColumns>['terms'][number]} term
*/
function isValidLength(term) {
const d =
/** @type {{ value?: number, type?: string, signCharacter?: string, unit?: string } | undefined} */ (
term.decoded
);
return (
term.type === TokenType.Dimension &&
typeof d?.unit === 'string' &&
typeof d.value === 'number' &&
isLengthValue(d.value, d.unit, false, true)
);
}
/**
* The component a value can only have come from: `column-width` takes a
* length, `column-count` an integer, and `auto` fits either.
*
* @param {ReturnType<typeof tokenizeColumns>['terms'][number]} term
* @return {'width' | 'count' | 'initial' | undefined}
*/
function componentRole(term) {
if (term.tokenCount !== 1) return undefined;
const d =
/** @type {{ value?: number | string, type?: string, signCharacter?: string } | undefined} */ (
term.decoded
);
if (
term.type === TokenType.Ident &&
typeof d?.value === 'string' &&
asciiLowerCase(d.value) === auto
) {
return 'initial';
}
if (
term.type === TokenType.Number &&
d?.type === 'integer' &&
d.signCharacter !== '-' &&
typeof d.value === 'number' &&
d.value > 0 &&
d.value <= Number.MAX_SAFE_INTEGER
) {
return 'count';
}
return isValidLength(term) ? 'width' : undefined;
}
/**
* @param {ReturnType<typeof tokenizeColumns>} parsed
* @return {('width' | 'count' | 'initial' | undefined)[]}
*/
function termRoles(parsed) {
return parsed.terms.map(componentRole);
}
/**
* Takes the shorthand apart into column-width and column-count.
* Combined with `||`, so components may appear in either order.
*
* @param {ReturnType<typeof tokenizeColumns>} parsed
* @param {('width' | 'count' | 'initial' | undefined)[]} [roles]
* @return {[string, string] | undefined}
*/
export function parseColumns(parsed, roles = termRoles(parsed)) {
const values = parsed.terms;
if (values.length > columnProperties.length) return undefined;
/** @type {(string | undefined)[]} */
const result = [undefined, undefined];
/** @type {string[]} */
const ambiguous = [];
for (const [position, component] of values.entries()) {
/** @type {'width' | 'count' | 'initial' | undefined} */
const role = roles[position];
if (role === undefined) return undefined;
const val = parsed.value.slice(component.start, component.end);
if (role === 'initial') {
ambiguous.push(val);
continue;
}
const index = role === 'width' ? 0 : 1;
if (result[index] !== undefined) return undefined;
result[index] = val;
}
for (const component of ambiguous) {
const free = result.indexOf(undefined);
if (free === -1) return undefined;
result[free] = component;
}
return /** @type {[string, string]} */ (
result.map((component) => component ?? auto)
);
}
/**
* Check if a declaration sets column properties beyond column-width/count.
* The `columns: <width> / <height>` form sets column-height via top-level slash.
*
* @param {Declaration} declaration
* @return {boolean}
*/
export const setsOtherColumnProperty = (declaration) =>
otherColumnProperties.has(asciiLowerCase(declaration.prop)) ||
(asciiLowerCase(declaration.prop) === columns &&
parsedValue(declaration).hasTopLevelSlash);
/** @param {string} v @return {boolean} */
const isKeywordOrUnresolved = (v) =>
v === auto || cssWideKeywords.has(v) || isUnresolved(v);
/** @param {Declaration} d @return {boolean} */
export function isValidColumns(d) {
if (!d.value) return false;
const parsed = parsedValue(d);
const roles = termRoles(parsed);
if (parseColumns(parsed, roles)) return true;
if (parsed.hasTopLevelSlash) return true;
if (parsed.terms.length === 1) {
const val = asciiLowerCase(
parsed.value.slice(parsed.terms[0].start, parsed.terms[0].end)
);
return isKeywordOrUnresolved(val) || roles[0] !== undefined;
}
if (parsed.terms.length === 2) {
/** @type {('width' | 'count' | 'initial' | 'unresolved')[]} */
const pairRoles = [];
for (const [position, term] of parsed.terms.entries()) {
const role = roles[position];
if (role !== undefined) {
pairRoles.push(role);
} else {
const val = asciiLowerCase(parsed.value.slice(term.start, term.end));
// CSS-wide keywords cannot combine with other tokens (CSS Cascading 4
// § 7.2); only unresolved functions keep a two-term value valid.
if (isUnresolved(val)) {
pairRoles.push('unresolved');
} else {
return false;
}
}
}
if (pairRoles[0] === 'width' && pairRoles[1] === 'width') return false;
if (pairRoles[0] === 'count' && pairRoles[1] === 'count') return false;
return true;
}
return false;
}
/** @param {Declaration} d @return {boolean} */
function isValidColumnProperty(d) {
const value = asciiLowerCase(d.value);
if (!value) return false;
if (isKeywordOrUnresolved(value)) return true;
const parsed = parsedValue(d);
return (
parsed.terms.length === 1 &&
componentRole(parsed.terms[0]) ===
(asciiLowerCase(d.prop) === 'column-width' ? 'width' : 'count')
);
}
/** @param {Declaration} d @return {boolean} */
export const isInvalid = (d) =>
!stylehacks.detect(d) &&
(asciiLowerCase(d.prop) === columns
? !isValidColumns(d)
: !isValidColumnProperty(d));