Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ reduceCalc('min(50px, calc(2 * 40px))');
// => '50px'
```

It accepts `precision`, `warnWhenCannotResolve`, `onParseError`, and `onWarn`:
It accepts `precision`, `unwrapSingleNegativeNumber`, `warnWhenCannotResolve`, `onParseError`,
and `onWarn`:

```js
const result = reduceCalc('calc(100% + var(--gap))', {
Expand All @@ -84,7 +85,30 @@ const result = reduceCalc('calc(100% + var(--gap))', {
Unlike the PostCSS plugin, the standalone reducer does not show warnings
by default; provide `onParseError` and/or `onWarn` if you want diagnostics.

### Options
### Standalone reducer options

#### `unwrapSingleNegativeNumber` (default: `false`)

Controls whether a finite negative result is serialized as a bare value or
wrapped in `calc()`. Keep the default when reducing declaration values; set it
to `true` when the surrounding CSS context requires a bare negative value, such
as a selector:

```js
reduceCalc('calc(5px - 10px)');
// => 'calc(-5px)'

reduceCalc('calc(5px - 10px)', { unwrapNegativeNumbers: true });
// => '-5px'
```

### PostCSS plugin options

These options apply when using the PostCSS plugin:

```js
postcss().use(calc({ precision: 10 }));
```

#### `precision` (default: `5`)

Expand Down Expand Up @@ -139,7 +163,11 @@ With `mediaQueries: true`, this becomes:

#### `selectors` (default: `false`)

Allows calc() usage as part of selectors.
Reduces `calc()` functions found in selectors. Selectors do not accept
`calc()` functions, so the plugin replaces them with their reduced values.
Finite negative results are serialized as bare values because a selector cannot
contain a `calc()` function; the plugin enables `unwrapSingleNegativeNumber` automatically
for selectors.

```js
var out = postcss()
Expand All @@ -163,11 +191,13 @@ Callback invoked when a `calc()` body fails to parse or simplify. Matches
[`@csstools/css-calc`][csstools-css-calc]'s shape:

```js
calc({
onParseError: (err, input) => {
throw err; // or log, route to a different channel, etc.
},
});
postcss().use(
calc({
onParseError: (err, input) => {
throw err; // or log, route to a different channel, etc.
},
})
);
```

When omitted, errors are reported via PostCSS `result.warn()` so the
Expand Down
20 changes: 16 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,17 @@ import reduceCalc, { hasPotentialMathFunction } from './reduce.js';
* @param {(target: import('postcss').ChildNode, value: string) => void} setProp
* @param {ResolvedOptions} options
* @param {import('postcss').Result} result
* @param {boolean} unwrapSingleNegativeNumber
* @return {void}
*/
function applyTransform(node, current, setProp, options, result) {
function applyTransform(
node,
current,
setProp,
options,
result,
unwrapSingleNegativeNumber
) {
if (!hasPotentialMathFunction(current)) {
return;
}
Expand All @@ -41,6 +49,7 @@ function applyTransform(node, current, setProp, options, result) {
onWarn: (message) => {
result.warn(message, { plugin: 'postcss-calc', node });
},
unwrapSingleNegativeNumber,
});
if (transformed !== current) {
setProp(node, transformed);
Expand Down Expand Up @@ -77,7 +86,8 @@ function pluginCreator(opts) {
/** @type {import('postcss').Declaration} */ (n).value = v;
},
options,
result
result,
false
);
}
if (node.type === 'atrule' && options.mediaQueries) {
Expand All @@ -88,7 +98,8 @@ function pluginCreator(opts) {
/** @type {import('postcss').AtRule} */ (n).params = v;
},
options,
result
result,
false
);
}
if (node.type === 'rule' && options.selectors) {
Expand All @@ -101,7 +112,8 @@ function pluginCreator(opts) {
/** @type {import('postcss').Rule} */ (n).selector = v;
},
options,
result
result,
true
);
}
});
Expand Down
47 changes: 36 additions & 11 deletions src/lib/serialize.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree
// Outer calc() is added only when the top-level result contains an
// arithmetic operator. A Sum inside a Product is the only place parens
// are ever required on valid canonical input.
// Outer calc() is added when the top-level result contains an arithmetic
// operator, or when a finite scalar is negative.

import { num, dim } from './node.js';

Expand All @@ -13,6 +12,7 @@ import { num, dim } from './node.js';
* @typedef {object} SerializeOptions
* @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5.
* @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`.
* @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative scalars without a wrapper. Internal selector-only mode.
*/

// Below this is float noise, not a value: `0.1 + 0.2 - 0.3` is 5.5e-17.
Expand Down Expand Up @@ -80,6 +80,20 @@ function serializeNumber(v) {
return text;
}

/**
* Round and serialize a finite scalar once so callers can use the same value
* to decide its syntactic context and render its text.
*
* @param {import('./node.js').Num | import('./node.js').Dim} node
* @param {number | false} prec
* @return {{value: number, text: string}}
*/
function serializeScalar(node, prec) {
const value = round(node.value, prec);
const text = `${serializeNumber(value)}${node.type === 'Dim' ? node.unit : ''}`;
return { value, text };
}

/**
* @param {Node} node
* @param {SerializeOptions} [opts]
Expand All @@ -98,6 +112,22 @@ function serialize(node, opts = {}) {
return `${calcName}(${degenerateKeyword(node.value)} * 1${node.unit})`;
}

if (node.type === 'Num' || node.type === 'Dim') {
const scalar = serializeScalar(node, prec);

// A finite negative scalar must stay inside calc() so CSS parses it as a
// calculation result (and can apply range clamping) rather than as an
// invalid bare value. Base this on the serialized value so tiny negative
// floating-point noise that rounds to zero does not get wrapped.
if (scalar.value < 0) {
return opts.unwrapSingleNegativeNumber
? scalar.text
: `${calcName}(${scalar.text})`;
}

return scalar.text;
}

// A grouped sum with a leading negative term is the canonical result of
// negating a parenthesized expression. Re-invert its terms for the body so
// the grouping survives as `-(...)` instead of becoming `-a - b`.
Expand All @@ -114,12 +144,7 @@ function serialize(node, opts = {}) {
return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`;
}

if (
node.type === 'Num' ||
node.type === 'Dim' ||
node.type === 'Ident' ||
node.type === 'Call'
) {
if (node.type === 'Ident' || node.type === 'Call') {
return serializeExpr(node, prec);
}

Expand All @@ -145,15 +170,15 @@ function serializeExpr(node, prec) {
if (isDegenerate(node.value)) {
return degenerateKeyword(node.value);
}
return serializeNumber(round(node.value, prec));
return serializeScalar(node, prec).text;
case 'Dim':
if (isDegenerate(node.value)) {
// Nested degenerate Dim wraps in calc() so the `<kw> * 1<unit>` form
// parses back as one Dim factor. The bare form round-trips wrong
// inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN.
return `calc(${degenerateKeyword(node.value)} * 1${node.unit})`;
}
return `${serializeNumber(round(node.value, prec))}${node.unit}`;
return serializeScalar(node, prec).text;
case 'Ident':
return node.name;
case 'Call': {
Expand Down
31 changes: 21 additions & 10 deletions src/reduce.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,15 @@ const BLOCK_CLOSE = new Map([
* @typedef {object} ReduceCalcOptions
* @property {number | false} [precision]
* @property {boolean} [warnWhenCannotResolve]
* @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative results without a `calc()` wrapper. Defaults to `false`.
* @property {(error: Error, input: string) => void} [onParseError] Invoked when parse/simplify throws.
* @property {(message: string) => void} [onWarn] Invoked when `warnWhenCannotResolve` is set and an expression cannot be reduced to a single value.
*/

/** @typedef {Required<Omit<ReduceCalcOptions, 'onParseError' | 'onWarn'>> & Pick<ReduceCalcOptions, 'onParseError' | 'onWarn'>} ResolvedReduceCalcOptions */

/**
* Fields threaded unchanged through the token-range walk.
* `value` is the original full property text, used only for the
* warnWhenCannotResolve message.
* Fields threaded through the internal token-range walk.
*
* @typedef {object} TransformContext
* @property {ResolvedReduceCalcOptions} options
Expand All @@ -51,7 +50,6 @@ const BLOCK_CLOSE = new Map([
* @property {number} end
* @property {import('./lib/node.js').Node} node
* @property {string} calcName
* @property {string} matchedName
*/

/**
Expand Down Expand Up @@ -105,7 +103,6 @@ function walkTokens(start, expectedClose, ctx, transform) {
end,
node,
calcName: isCalc ? name : 'calc',
matchedName: name,
});
} catch (error) {
const err = error instanceof Error ? error : new Error('Error');
Expand All @@ -117,6 +114,17 @@ function walkTokens(start, expectedClose, ctx, transform) {
return ctx.tokens.length - 1;
}

/**
* @param {import('./lib/node.js').Node} node
* @return {boolean}
*/
function isUnresolvedResult(node) {
if (node.type === 'Sum' || node.type === 'Product') {
return true;
}
return node.type === 'Call' && isSupportedMathFunction(node.name);
}

/**
* Simplify every supported CSS math function in a component-value string.
* Text outside those functions is preserved byte-for-byte.
Expand All @@ -131,7 +139,12 @@ function reduceCalc(value, opts) {
}

/** @type {ResolvedReduceCalcOptions} */
const options = { precision: 5, warnWhenCannotResolve: false, ...opts };
const options = {
precision: 5,
warnWhenCannotResolve: false,
unwrapSingleNegativeNumber: false,
...opts,
};
const tokens = cssTokenize({ css: value });
/** @type {Replacement[]} */
const replacements = [];
Expand All @@ -147,11 +160,9 @@ function reduceCalc(value, opts) {
const text = serialize(replacement.node, {
precision: options.precision,
calcName: replacement.calcName,
unwrapSingleNegativeNumber: options.unwrapSingleNegativeNumber,
});
if (
options.warnWhenCannotResolve &&
text.startsWith(`${replacement.matchedName}(`)
) {
if (options.warnWhenCannotResolve && isUnresolvedResult(replacement.node)) {
options.onWarn?.('Could not reduce expression: ' + value);
}
output += value.slice(lastIndex, replacement.start) + text;
Expand Down
16 changes: 8 additions & 8 deletions test/conformance/csstools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ describe('csstools round/mod/rem/abs/sign', () => {
test('csstools round: each strategy', () => {
assert.equal(out('round(up, 1.1, 1)'), '2');
assert.equal(out('round(down, 1.9, 1)'), '1');
assert.equal(out('round(to-zero, -1.9, 1)'), '-1');
assert.equal(out('round(to-zero, -1.9, 1)'), 'calc(-1)');
assert.equal(out('round(nearest, 1.5, 1)'), '2');
});

Expand All @@ -215,12 +215,12 @@ describe('csstools round/mod/rem/abs/sign', () => {
test('csstools mod: spec examples', () => {
assert.equal(out('mod(18, 5)'), '3');
assert.equal(out('mod(-18, 5)'), '2');
assert.equal(out('mod(18, -5)'), '-2');
assert.equal(out('mod(18, -5)'), 'calc(-2)');
});

test('csstools rem: spec examples', () => {
assert.equal(out('rem(18, 5)'), '3');
assert.equal(out('rem(-18, 5)'), '-3');
assert.equal(out('rem(-18, 5)'), 'calc(-3)');
assert.equal(out('rem(18, -5)'), '3');
});

Expand All @@ -240,10 +240,10 @@ describe('csstools round/mod/rem/abs/sign', () => {
});

test('csstools sign: number, dim, opaque', () => {
assert.equal(out('sign(-5)'), '-1');
assert.equal(out('sign(-5)'), 'calc(-1)');
assert.equal(out('sign(5)'), '1');
assert.equal(out('sign(0)'), '0');
assert.equal(out('sign(-5px)'), '-1');
assert.equal(out('sign(-5px)'), 'calc(-1)');
assert.equal(out('sign(var(--x))'), 'sign(var(--x))');
});

Expand Down Expand Up @@ -290,7 +290,7 @@ describe('csstools trig:', () => {
});

test('csstools trig: cos(180deg) → -1', () => {
assert.equal(out('cos(180deg)'), '-1');
assert.equal(out('cos(180deg)'), 'calc(-1)');
});

test('csstools trig: cos(60deg) → 0.5000000000000001 (full precision)', () => {
Expand Down Expand Up @@ -330,7 +330,7 @@ describe('csstools trig:', () => {
});

test('csstools inverse-trig: asin(-1) → -90deg', () => {
assert.equal(out('asin(-1)'), '-90deg');
assert.equal(out('asin(-1)'), 'calc(-90deg)');
});

test('csstools inverse-trig: asin(0.5) → 30.000000000000004deg', () => {
Expand Down Expand Up @@ -370,7 +370,7 @@ describe('csstools trig:', () => {
});

test('csstools atan2: (-1, -1) → -135deg', () => {
assert.equal(out('atan2(-1, -1)'), '-135deg');
assert.equal(out('atan2(-1, -1)'), 'calc(-135deg)');
});

test('csstools atan2: cross-unit-same-base (1in, 96px) → 45deg', () => {
Expand Down
Loading