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
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ statement is left as is, to fallback to the [W3C calc() implementation].
npm install postcss-calc
```

## Usage
## PostCSS usage

```js
// dependencies
Expand Down Expand Up @@ -52,6 +52,38 @@ h1 {

Checkout [tests] for more examples.

## Use the reducer without PostCSS

For a single CSS component-value string, import the dedicated reducer entry
point. It reduces `calc()` and the supported CSS math functions it finds while
leaving all other text untouched.

```js
import reduceCalc from 'postcss-calc/reduce';

reduceCalc('calc(1in + 10px)');
// => '1.10417in'

reduceCalc('min(50px, calc(2 * 40px))');
// => '50px'
```

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

```js
const result = reduceCalc('calc(100% + var(--gap))', {
precision: false,
warnWhenCannotResolve: true,
onWarn: console.warn,
onParseError(error, input) {
console.error(`Invalid calculation: ${input}`, error);
},
});
```

Unlike the PostCSS plugin, the standalone reducer does not show warnings
by default; provide `onParseError` and/or `onWarn` if you want diagnostics.

### Options

#### `precision` (default: `5`)
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
".": {
"types": "./types/index.d.ts",
"default": "./src/index.js"
},
"./reduce": {
"types": "./types/reduce.d.ts",
"default": "./src/reduce.js"
}
},
"files": [
Expand Down
202 changes: 22 additions & 180 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,5 @@
// PostCSS adapter. Walks declaration values (and optionally @rule params
// and selectors), feeds calc() bodies through tokenize → parse → simplify
// → serialize, and writes the result back.
import {
tokenize as cssTokenize,
TokenType as CssType,
} from '@csstools/css-tokenizer';
import { tokenizeTokens } from './lib/tokenizer.js';
import { parse } from './lib/parser.js';
import { simplify } from './lib/simplify.js';
import { isSupportedMathFunction } from './lib/simplify/call.js';
import { serialize } from './lib/serialize.js';

const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;

const BLOCK_CLOSE = new Map([
[CssType.OpenParen, CssType.CloseParen],
[CssType.OpenSquare, CssType.CloseSquare],
[CssType.OpenCurly, CssType.CloseCurly],
]);

/**
* @typedef {object} TransformValueOptions
* @property {number | false} [precision]
* @property {boolean} [warnWhenCannotResolve]
* @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<TransformValueOptions, 'onParseError' | 'onWarn'>> & Pick<TransformValueOptions, 'onParseError' | 'onWarn'>} ResolvedTransformOptions */
// PostCSS adapter over the standalone component-value reducer.
import reduceCalc, { hasPotentialMathFunction } from './reduce.js';

/**
* @typedef {object} PostCssCalcOptions
Expand All @@ -41,140 +13,7 @@ const BLOCK_CLOSE = new Map([
/** @typedef {Required<Omit<PostCssCalcOptions, 'onParseError'>> & Pick<PostCssCalcOptions, 'onParseError'>} ResolvedOptions */

/**
* Fields threaded unchanged through the token-range walk.
* `value` is the original full property text, used only for the
* warnWhenCannotResolve message.
*
* @typedef {object} TransformContext
* @property {ResolvedTransformOptions} options
* @property {string} value
* @property {import('@csstools/css-tokenizer').CSSToken[]} tokens
* @property {Replacement[]} replacements
*/

/**
* @typedef {object} Replacement
* @property {number} start
* @property {number} end
* @property {import('./lib/node.js').Node} node
* @property {string} calcName
* @property {string} matchedName
*/

/**
* Walk one component-value level. Unsupported functions and simple blocks are
* traversed, while a supported function is treated as one opaque calculation
* even when parsing it fails. A missing closer consumes through EOF, matching
* CSS component-value parsing's error recovery.
*
* @param {number} start
* @param {import('@csstools/css-tokenizer').TokenType | undefined} expectedClose
* @param {TransformContext} ctx
* @param {boolean} transform
* @return {number} Index of the matching closer, or the EOF token.
*/
function walkTokens(start, expectedClose, ctx, transform) {
for (let i = start; i < ctx.tokens.length; i++) {
const token = ctx.tokens[i];
if (token[0] === CssType.EOF || token[0] === expectedClose) {
return i;
}

const blockClose = BLOCK_CLOSE.get(token[0]);
if (blockClose) {
i = walkTokens(i + 1, blockClose, ctx, transform);
continue;
}

if (token[0] !== CssType.Function) {
continue;
}

const name = token[4].value;
const isCalc = MATCH_CALC.test(name);
const isMath = !isCalc && isSupportedMathFunction(name);
if (!transform || (!isCalc && !isMath)) {
i = walkTokens(i + 1, CssType.CloseParen, ctx, transform);
continue;
}

// Locate the complete outer function without transforming its children.
const close = walkTokens(i + 1, CssType.CloseParen, ctx, false);
const closed = ctx.tokens[close][0] === CssType.CloseParen;
const end = closed ? ctx.tokens[close][3] + 1 : ctx.value.length;
const sliceStart = isCalc ? i + 1 : i;
const sliceEnd = closed ? close + (isCalc ? 0 : 1) : close;
const inputStart = isCalc ? token[3] + 1 : token[2];
const inputEnd = closed && isCalc ? ctx.tokens[close][2] : end;
const contents = ctx.value.slice(inputStart, inputEnd);
try {
const node = simplify(
parse(tokenizeTokens(ctx.tokens.slice(sliceStart, sliceEnd), end))
);
ctx.replacements.push({
start: token[2],
end,
node,
calcName: isCalc ? name : 'calc',
matchedName: name,
});
} catch (error) {
const err = error instanceof Error ? error : new Error('Error');
ctx.options.onParseError?.(err, contents);
}
i = close;
}

return ctx.tokens.length - 1;
}

/**
* @param {string} value
* @param {TransformValueOptions} [opts]
* @return {string}
*/
function transformValue(value, opts) {
/** @type {ResolvedTransformOptions} */
const options = {
precision: 5,
warnWhenCannotResolve: false,
...opts,
};

const tokens = cssTokenize({ css: value });
/** @type {Replacement[]} */
const replacements = [];
const ctx = { options, value, tokens, replacements };
walkTokens(0, undefined, ctx, true);

/** @type {(Replacement & {text: string})[]} */
const serialized = replacements.map((replacement) => {
const text = serialize(replacement.node, {
precision: options.precision,
calcName: replacement.calcName,
});
if (
options.warnWhenCannotResolve &&
text.startsWith(`${replacement.matchedName}(`)
) {
options.onWarn?.('Could not reduce expression: ' + value);
}
return { ...replacement, text };
});

let output = value;
for (let i = serialized.length - 1; i >= 0; i--) {
const replacement = serialized[i];
output =
output.slice(0, replacement.start) +
replacement.text +
output.slice(replacement.end);
}
return output;
}

/**
* Runs `transformValue` over one text property of a decl/atrule/rule node
* Runs `reduceCalc` over one text property of a decl/atrule/rule node
* and updates it in place.
* `setProp` closes over the property name and the concrete node type at
* each call site, since `Declaration`/`AtRule`/`Rule` don't share a typed
Expand All @@ -188,21 +27,24 @@ function transformValue(value, opts) {
* @return {void}
*/
function applyTransform(node, current, setProp, options, result) {
setProp(
node,
transformValue(current, {
precision: options.precision,
warnWhenCannotResolve: options.warnWhenCannotResolve,
onParseError:
options.onParseError ??
((error) => {
result.warn(error.message, { node });
}),
onWarn: (message) => {
result.warn(message, { plugin: 'postcss-calc', node });
},
})
);
if (!hasPotentialMathFunction(current)) {
return;
}
const transformed = reduceCalc(current, {
precision: options.precision,
warnWhenCannotResolve: options.warnWhenCannotResolve,
onParseError:
options.onParseError ??
((error) => {
result.warn(error.message, { node });
}),
onWarn: (message) => {
result.warn(message, { plugin: 'postcss-calc', node });
},
});
if (transformed !== current) {
setProp(node, transformed);
}
}

/**
Expand Down Expand Up @@ -273,4 +115,4 @@ pluginCreator.postcss = true;
export default /** @type import('postcss').PluginCreator<PostCssCalcOptions>*/ (
pluginCreator
);
export { transformValue as reduceCalc, pluginCreator as 'module.exports' };
export { pluginCreator as 'module.exports' };
10 changes: 5 additions & 5 deletions src/lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function mkSum(rawTerms) {
pushSumTerm(flat, t);
}
if (flat.length === 0) {
return { type: 'Num', value: 0 };
return num(0);
}
if (flat.length === 1 && flat[0].sign === 1) {
return flat[0].node;
Expand Down Expand Up @@ -101,10 +101,10 @@ function pushSumTerm(out, term) {
// canonical-form rule downstream code relies on.
if (sign === -1) {
if (node.type === 'Num') {
node = { type: 'Num', value: -node.value };
node = num(-node.value);
sign = 1;
} else if (node.type === 'Dim') {
node = { type: 'Dim', value: -node.value, unit: node.unit };
node = dim(-node.value, node.unit);
sign = 1;
}
}
Expand All @@ -128,7 +128,7 @@ function mkProduct(rawFactors) {
pushProductFactor(flat, f);
}
if (flat.length === 0) {
return { type: 'Num', value: 1 };
return num(1);
}
if (flat.length === 1 && flat[0].exponent === 1) {
return flat[0].node;
Expand Down Expand Up @@ -184,7 +184,7 @@ function negate(node) {
}
// Opaque (Ident, Call, Product): wrap as a single negative-sign term —
// the only case where sign=-1 remains on a SumTerm.
return { type: 'Sum', terms: [{ sign: -1, node }] };
return mkSum([{ sign: -1, node }]);
}

export { num, dim, ident, call, mkSum, mkProduct, negate };
Loading