From 9ac8ff1188178e09f722066daf10b1c028a8c62d Mon Sep 17 00:00:00 2001 From: phuoccss Date: Wed, 2 Sep 2026 00:18:07 +0800 Subject: [PATCH 1/3] feat: add reduceCalc for usage without PostCSS --- src/index.js | 58 ++++++++++++++++++++++++++++++++---------------- types/index.d.ts | 25 +++++++++++++++++---- 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/index.js b/src/index.js index 85dd1d9..3a09c16 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,16 @@ const BLOCK_CLOSE = new Map([ [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> & Pick} ResolvedTransformOptions */ + /** * @typedef {object} PostCssCalcOptions * @property {number | false} [precision] @@ -36,9 +46,7 @@ const BLOCK_CLOSE = new Map([ * warnWhenCannotResolve message. * * @typedef {object} TransformContext - * @property {ResolvedOptions} options - * @property {import('postcss').Result} result - * @property {import('postcss').ChildNode} item + * @property {ResolvedTransformOptions} options * @property {string} value * @property {import('@csstools/css-tokenizer').CSSToken[]} tokens * @property {Replacement[]} replacements @@ -112,11 +120,7 @@ function walkTokens(start, expectedClose, ctx, transform) { }); } catch (error) { const err = error instanceof Error ? error : new Error('Error'); - if (ctx.options.onParseError) { - ctx.options.onParseError(err, contents); - } else { - ctx.result.warn(err.message, { node: ctx.item }); - } + ctx.options.onParseError?.(err, contents); } i = close; } @@ -126,16 +130,21 @@ function walkTokens(start, expectedClose, ctx, transform) { /** * @param {string} value - * @param {ResolvedOptions} options - * @param {import('postcss').Result} result - * @param {import('postcss').ChildNode} item + * @param {TransformValueOptions} [opts] * @return {string} */ -function transformValue(value, options, result, item) { +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, result, item, value, tokens, replacements }; + const ctx = { options, value, tokens, replacements }; walkTokens(0, undefined, ctx, true); /** @type {(Replacement & {text: string})[]} */ @@ -148,10 +157,7 @@ function transformValue(value, options, result, item) { options.warnWhenCannotResolve && text.startsWith(`${replacement.matchedName}(`) ) { - result.warn('Could not reduce expression: ' + value, { - plugin: 'postcss-calc', - node: item, - }); + options.onWarn?.('Could not reduce expression: ' + value); } return { ...replacement, text }; }); @@ -182,7 +188,21 @@ function transformValue(value, options, result, item) { * @return {void} */ function applyTransform(node, current, setProp, options, result) { - setProp(node, transformValue(current, options, result, node)); + 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 }); + }, + }) + ); } /** @@ -253,4 +273,4 @@ pluginCreator.postcss = true; export default /** @type import('postcss').PluginCreator*/ ( pluginCreator ); -export { pluginCreator as 'module.exports' }; +export { transformValue as reduceCalc, pluginCreator as 'module.exports' }; diff --git a/types/index.d.ts b/types/index.d.ts index b14ad8a..4d15261 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,3 +1,16 @@ +export type TransformValueOptions = { + precision?: number | false; + warnWhenCannotResolve?: boolean; + /** + * Invoked when parse/simplify throws. + */ + onParseError?: (error: Error, input: string) => void; + /** + * Invoked when `warnWhenCannotResolve` is set and an expression cannot be reduced to a single value. + */ + onWarn?: (message: string) => void; +}; +export type ResolvedTransformOptions = Required> & Pick; export type PostCssCalcOptions = { precision?: number | false; warnWhenCannotResolve?: boolean; @@ -10,9 +23,7 @@ export type PostCssCalcOptions = { }; export type ResolvedOptions = Required> & Pick; export type TransformContext = { - options: ResolvedOptions; - result: import('postcss').Result; - item: import('postcss').ChildNode; + options: ResolvedTransformOptions; value: string; tokens: import('@csstools/css-tokenizer').CSSToken[]; replacements: Replacement[]; @@ -24,6 +35,12 @@ export type Replacement = { calcName: string; matchedName: string; }; +/** + * @param {string} value + * @param {TransformValueOptions} [opts] + * @return {string} + */ +declare function transformValue(value: string, opts?: TransformValueOptions): string; /** * @param {PostCssCalcOptions} [opts] * @return {import('postcss').Plugin} @@ -34,4 +51,4 @@ declare namespace pluginCreator { } declare const _default: import('postcss').PluginCreator; export default _default; -export { pluginCreator as 'module.exports' }; +export { transformValue as reduceCalc, pluginCreator as 'module.exports' }; From 020a3111b5c262562b6199e249b5d05a87cf2761 Mon Sep 17 00:00:00 2001 From: phuoccss Date: Wed, 2 Sep 2026 03:48:17 +0800 Subject: [PATCH 2/3] chore: add tests for reduceCalc --- test/unit/reduceCalc.test.mjs | 352 ++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 test/unit/reduceCalc.test.mjs diff --git a/test/unit/reduceCalc.test.mjs b/test/unit/reduceCalc.test.mjs new file mode 100644 index 0000000..879ebe7 --- /dev/null +++ b/test/unit/reduceCalc.test.mjs @@ -0,0 +1,352 @@ +// Standalone reduceCalc tests. Mirrors test/unit/plugin.test.mjs for cases +// that operate on a CSS value string rather than PostCSS node walking. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { reduceCalc } from '../../src/index.js'; + +function reduceWithWarnings(value, opts = {}) { + const warnings = []; + const output = reduceCalc(value, { + ...opts, + onWarn: (message) => { + warnings.push(message); + opts.onWarn?.(message); + }, + }); + return { output, warnings }; +} + +function assertIdempotent(value, opts = {}) { + const first = reduceWithWarnings(value, opts); + const second = reduceWithWarnings(first.output, opts); + + assert.notEqual(first.output, value); + assert.equal(second.output, first.output); + assert.deepEqual(second.warnings, first.warnings); +} + +// --- Basic pipeline ------------------------------------------------------ +describe('reduceCalc: basic pipeline', () => { + test('reduceCalc: reduces simple calc in a value', () => { + assert.equal(reduceCalc('calc(1px + 2px)'), '3px'); + }); + + test('reduceCalc: preserves non-calc values', () => { + assert.equal(reduceCalc('red'), 'red'); + }); + + test('reduceCalc: ordinary values remain byte-for-byte unchanged', () => { + assert.equal(reduceCalc('"calc(1px + 2px)"'), '"calc(1px + 2px)"'); + assert.equal(reduceCalc('url(x)'), 'url(x)'); + assert.equal(reduceCalc(' red\\9 '), ' red\\9 '); + }); + + test('reduceCalc: simple resolved results preserve canonical token text', () => { + assert.equal(reduceCalc('calc(1px + 2px)'), '3px'); + assert.equal(reduceCalc('calc(10% - 2%)'), '8%'); + assert.equal(reduceCalc('calc(1 / 4)'), '.25'); + assert.equal(reduceCalc('calc(-2px + 1px)'), '-1px'); + assert.equal(reduceCalc('calc(1PX + 2PX)'), '3px'); + }); + + test('reduceCalc: multiple calcs in one value', () => { + assert.equal(reduceCalc('calc(1px + 1px) calc(2px + 2px)'), '2px 4px'); + }); + + test('reduceCalc: one value preserves bytes around several token-slice calculations', () => { + assert.equal( + reduceCalc( + '\\66 oo calc(/*a*/-2px + +5px) /\\*keep*\\/ MIN(4px,2px)\\9' + ), + '\\66 oo 3px /\\*keep*\\/ 2px\\9' + ); + }); + + test('reduceCalc: transformations are idempotent', () => { + const opts = { warnWhenCannotResolve: true }; + assertIdempotent('calc(1px + 2px) calc(2px + 3px)', opts); + const unresolved = 'calc(100% + var(--x))'; + const first = reduceWithWarnings(unresolved, opts); + const second = reduceWithWarnings(first.output, opts); + assert.equal(first.output, unresolved); + assert.equal(second.output, first.output); + assert.deepEqual(second.warnings, first.warnings); + }); + + test('reduceCalc: removes leading zero from resolved decimals', () => { + assert.equal(reduceCalc('calc(1px / 4)'), '.25px'); + assert.equal(reduceCalc('calc(1 / 2000000)'), '5e-7'); + }); + + test('reduceCalc: preserves grouping through unary negation', () => { + assert.equal( + reduceCalc('calc(-(var(--a) + var(--b)))'), + 'calc(-(var(--a) + var(--b)))' + ); + assert.equal( + reduceCalc('calc(-(10px + var(--a)))'), + 'calc(-(10px + var(--a)))' + ); + }); + + test('reduceCalc: preserves grouping for opaque subtraction', () => { + assert.equal( + reduceCalc('calc(5px - (var(--var-1) + var(--var-2)))'), + 'calc(5px - (var(--var-1) + var(--var-2)))' + ); + assert.equal( + reduceCalc('calc(var(--a) - (var(--b) + var(--c)))'), + 'calc(var(--a) - (var(--b) + var(--c)))' + ); + assert.equal( + reduceCalc('calc(var(--a) - (var(--b) - var(--c)))'), + 'calc(var(--a) - (var(--b) - var(--c)))' + ); + assert.equal( + reduceCalc('calc(5px - (10px + var(--a)))'), + 'calc(5px - (10px + var(--a)))' + ); + }); + + test('reduceCalc: preserves nested opaque grouping and simplifies var fallbacks', () => { + assert.equal( + reduceCalc( + 'calc(var(--a) - (var(--b) - (var(--c, calc(1px + 2px)) + var(--d))))' + ), + 'calc(var(--a) - (var(--b) - (var(--c, 3px) + var(--d))))' + ); + }); + + test('reduceCalc: vendor-prefix calcs get the same simplification', () => { + assert.equal(reduceCalc('-webkit-calc(1px + 2px)'), '3px'); + }); + + test('reduceCalc: vendor-prefix wrapper preserved when expression cannot fully resolve', () => { + assert.equal( + reduceCalc('-webkit-calc(1px + var(--x))'), + '-webkit-calc(1px + var(--x))' + ); + assert.equal( + reduceCalc('-moz-calc(1px + var(--x))'), + '-moz-calc(1px + var(--x))' + ); + }); +}); + +// --- warnWhenCannotResolve ----------------------------------------------- +test('reduceCalc: warnWhenCannotResolve surfaces unresolved expressions', () => { + const { warnings } = reduceWithWarnings('calc(100% + var(--x))', { + warnWhenCannotResolve: true, + }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /Could not reduceWithWarnings/); +}); + +test('reduceCalc: no warning when expression fully resolves', () => { + const { warnings } = reduceWithWarnings('calc(1px + 2px)', { + warnWhenCannotResolve: true, + }); + assert.equal(warnings.length, 0); +}); + +// --- mediaQueries / selectors (value strings the plugin would pass) ------ +describe('reduceCalc: media query params', () => { + test('reduceCalc: reduces calc in a media-query param string', () => { + assert.equal( + reduceCalc('(min-width: calc(100px + 100px))'), + '(min-width: 200px)' + ); + }); + + test('reduceCalc: media-query param transformations are idempotent', () => { + assertIdempotent('(min-width: calc(100px + 100px))'); + }); +}); + +// --- onParseError -------------------------------------------------------- +describe('reduceCalc: OnParseError', () => { + test('reduceCalc: default behavior on parse failure leaves the value unchanged', () => { + assert.equal(reduceCalc('calc(1 /)'), 'calc(1 /)'); + }); + + test('reduceCalc: onParseError receives the error and inner calc body', () => { + const captured = []; + const output = reduceCalc('calc(1 /)', { + onParseError: (err, input) => + captured.push({ message: err.message, input }), + }); + assert.equal(output, 'calc(1 /)'); + assert.equal(captured.length, 1); + assert.match(captured[0].message, /Unexpected token/); + assert.equal(captured[0].input, '1 /'); + }); + + test('reduceCalc: onParseError receives the inner calc body, not the full value', () => { + const inputs = []; + reduceCalc('calc(1 /) calc(2 /)', { + onParseError: (_, input) => inputs.push(input), + }); + assert.deepEqual(inputs, ['1 /', '2 /']); + }); + + test('reduceCalc: division by zero now folds to infinity (no error)', () => { + const captured = []; + const output = reduceCalc('calc(1px / 0)', { + onParseError: (err) => captured.push(err), + }); + assert.equal(captured.length, 0); + assert.equal(output, 'calc(infinity * 1px)'); + }); +}); + +// --- precision ----------------------------------------------------------- +describe('reduceCalc: Precision', () => { + test('reduceCalc: precision option applies to numeric output', () => { + assert.equal(reduceCalc('calc(1in + 10px)', { precision: 2 }), '1.1in'); + }); + + test('reduceCalc: precision false keeps full float precision', () => { + assert.match( + reduceCalc('calc(1in + 10px)', { precision: false }), + /1\.1041666666/ + ); + }); + + test('reduceCalc: precision 0 rounds to whole numbers', () => { + assert.equal(reduceCalc('calc(1in + 10px)', { precision: 0 }), '1in'); + }); +}); + +// --- Option combinations ------------------------------------------------- +describe('reduceCalc: option combinations', () => { + test('reduceCalc: onParseError catches errors in a media-query param string', () => { + const errors = []; + reduceCalc('(min-width: calc(1px /))', { + onParseError: (err) => errors.push(err), + }); + assert.equal(errors.length, 1); + }); + + test('reduceCalc: reduces calc() in selector text', () => { + assert.match(reduceCalc('a:nth-child(calc(1 + 2))'), /:nth-child\(3\)/); + }); + + test('reduceCalc: transforms selector text in place', () => { + assert.equal(reduceCalc('a:nth-child(calc(1 + 2))'), 'a:nth-child(3)'); + }); + + test('reduceCalc: selector transformations are idempotent', () => { + assertIdempotent('a:nth-child(calc(1 + 2))'); + }); + + test('reduceCalc: onParseError does not fire for fully-resolved inputs', () => { + const errors = []; + reduceCalc('calc(1px + 2px)', { + onParseError: (err) => errors.push(err), + }); + assert.equal(errors.length, 0); + }); + + test('reduceCalc: options are no-ops on values with no calc()', () => { + const { output, warnings } = reduceWithWarnings('red', { + warnWhenCannotResolve: true, + }); + assert.equal(output, 'red'); + assert.equal(warnings.length, 0); + assert.equal(reduceCalc('10px 20px'), '10px 20px'); + }); +}); + +// --- Bare math functions (issue #189) ----------------------------------- +describe('reduceCalc: bare math functions', () => { + test('reduceCalc: simplifies bare min() outside of calc()', () => { + assert.equal( + reduceCalc('min(360px, 100% - 24px - 24px)'), + 'min(360px, 100% - 48px)' + ); + }); + + test('reduceCalc: detects escaped math-function names', () => { + assert.equal(reduceCalc('c\\61 lc(1px + 2px)'), '3px'); + assert.equal(reduceCalc('m\\69 n(1px, 2px)'), '1px'); + }); + + test('reduceCalc: simplifies bare max() outside of calc()', () => { + assert.equal(reduceCalc('max(1px, 2px, 3px)'), '3px'); + }); + + test('reduceCalc: simplifies bare clamp() outside of calc()', () => { + assert.equal(reduceCalc('clamp(0px, 5px, 10px)'), '5px'); + }); + + test('reduceCalc: simplifies bare math functions case-insensitively', () => { + assert.equal(reduceCalc('MIN(1px, 2px)'), '1px'); + }); + + test('reduceCalc: simplifies a supported bare function from the dispatcher', () => { + assert.equal(reduceCalc('pow(2, 3)'), '8'); + }); + + test('reduceCalc: leaves unsupported bare functions untouched', () => { + assert.equal(reduceCalc('unknown(1px + 2px)'), 'unknown(1px + 2px)'); + }); + + test('reduceCalc: supported math is found inside unsupported functions', () => { + assert.equal(reduceCalc('unknown(calc(1px + 2px))'), 'unknown(3px)'); + }); + + test('reduceCalc: supported math is found inside nested simple blocks', () => { + assert.equal( + reduceCalc('unknown([calc(1px + 2px)] {max(3px, 4px)})'), + 'unknown([3px] {4px})' + ); + }); + + test('reduceCalc: a failing supported outer function suppresses its children', () => { + const inputs = []; + const fixture = 'calc(calc(1 /) + calc(1px + 2px))'; + const output = reduceCalc(fixture, { + onParseError: (_, input) => inputs.push(input), + }); + assert.equal(output, fixture); + assert.deepEqual(inputs, ['calc(1 /) + calc(1px + 2px)']); + }); + + test('reduceCalc: stray malformed closers do not hide later calculations', () => { + assert.equal(reduceCalc('] calc(1px + 2px)'), '] 3px'); + }); + + test('reduceCalc: an unclosed function consumes through the end of a value', () => { + assert.equal(reduceCalc('calc(1px + 2px'), '3px'); + }); + + test('reduceCalc: leaves opaque-arg bare min() preserved', () => { + assert.equal(reduceCalc('min(1px, var(--x))'), 'min(1px, var(--x))'); + }); +}); + +// --- Source-range preservation ------------------------------------------ +test('reduceCalc: IE backslash hack survives the outer walk untouched', () => { + assert.equal(reduceCalc('calc(1px + 2px)\\9'), '3px\\9'); +}); + +describe('reduceCalc: Escaped Content', () => { + test('reduceCalc: escaped content value survives the outer walk untouched', () => { + assert.equal(reduceCalc('"\\e901"'), '"\\e901"'); + }); + + test('reduceCalc: unicode-range descriptor survives the outer walk untouched', () => { + assert.equal(reduceCalc('U+0025-00FF'), 'U+0025-00FF'); + }); + + test('reduceCalc: url() contents are opaque, even when they look like calc()', () => { + assert.equal(reduceCalc('url(calc(1px).png)'), 'url(calc(1px).png)'); + }); + + test('reduceCalc: grid line names survive alongside a reduced calc() term', () => { + assert.equal( + reduceCalc('[full-start] calc(1px + 2px) [full-end]'), + '[full-start] 3px [full-end]' + ); + }); +}); From 75b53428a1a8d5022957d408f12440a67b978b0b Mon Sep 17 00:00:00 2001 From: phuoccss Date: Wed, 2 Sep 2026 21:28:46 +0800 Subject: [PATCH 3/3] chore: fix tests --- test/unit/reduceCalc.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/reduceCalc.test.mjs b/test/unit/reduceCalc.test.mjs index 879ebe7..3033ee4 100644 --- a/test/unit/reduceCalc.test.mjs +++ b/test/unit/reduceCalc.test.mjs @@ -139,7 +139,7 @@ test('reduceCalc: warnWhenCannotResolve surfaces unresolved expressions', () => warnWhenCannotResolve: true, }); assert.equal(warnings.length, 1); - assert.match(warnings[0], /Could not reduceWithWarnings/); + assert.match(warnings[0], /Could not reduce/); }); test('reduceCalc: no warning when expression fully resolves', () => {