diff --git a/README.md b/README.md index 516c6b2..36f4022 100755 --- a/README.md +++ b/README.md @@ -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 @@ -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`) diff --git a/package.json b/package.json index 65098bb..e842030 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ ".": { "types": "./types/index.d.ts", "default": "./src/index.js" + }, + "./reduce": { + "types": "./types/reduce.d.ts", + "default": "./src/reduce.js" } }, "files": [ diff --git a/src/index.js b/src/index.js index 3a09c16..730885b 100644 --- a/src/index.js +++ b/src/index.js @@ -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> & Pick} ResolvedTransformOptions */ +// PostCSS adapter over the standalone component-value reducer. +import reduceCalc, { hasPotentialMathFunction } from './reduce.js'; /** * @typedef {object} PostCssCalcOptions @@ -41,140 +13,7 @@ const BLOCK_CLOSE = new Map([ /** @typedef {Required> & Pick} 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 @@ -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); + } } /** @@ -273,4 +115,4 @@ pluginCreator.postcss = true; export default /** @type import('postcss').PluginCreator*/ ( pluginCreator ); -export { transformValue as reduceCalc, pluginCreator as 'module.exports' }; +export { pluginCreator as 'module.exports' }; diff --git a/src/lib/node.js b/src/lib/node.js index 1148b2e..eb85973 100644 --- a/src/lib/node.js +++ b/src/lib/node.js @@ -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; @@ -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; } } @@ -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; @@ -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 }; diff --git a/src/lib/parser.js b/src/lib/parser.js index 44d4664f..cd90ff2 100644 --- a/src/lib/parser.js +++ b/src/lib/parser.js @@ -10,15 +10,6 @@ import { mkSum, mkProduct, negate, num, dim, ident, call } from './node.js'; * @typedef {(p: Parser, token: Token) => Node} PrefixParselet */ -/** - * @param {Token} t - * @param {string} value - * @return {boolean} - */ -function isPunct(t, value) { - return t.type === 'punct' && t.value === value; -} - /** * §10.9 — case-insensitive except for NaN. * @param {string} name @@ -81,6 +72,39 @@ class Parser { return t; } + /** + * @param {string} value + * @param {string} [value2] + * @return {boolean} + */ + isPunct(value, value2) { + const t = this.peek(); + return ( + t.type === 'punct' && + (t.value === value || (value2 !== undefined && t.value === value2)) + ); + } + + /** + * @param {string} value + * @return {boolean} + */ + matchPunct(value) { + if (this.isPunct(value)) { + this.next(); + return true; + } + return false; + } + + /** + * @param {string} value + * @return {Token} + */ + expectPunct(value) { + return this.expect('punct', value); + } + /** * @param {number} [minBp] * @return {Node} @@ -111,14 +135,7 @@ class Parser { sign: /** @type {1 | -1} */ (token.value === '+' ? 1 : -1), node: this.parseExpr(ADD_BP + 1), }); - const next = this.peek(); - if ( - next.type !== 'punct' || - (next.value !== '+' && next.value !== '-') - ) { - break; - } - } while (ADD_BP >= minBp); + } while (this.isPunct('+', '-')); left = mkSum(terms); continue; } @@ -132,14 +149,7 @@ class Parser { exponent: /** @type {1 | -1} */ (token.value === '*' ? 1 : -1), node: this.parseExpr(MUL_BP + 1), }); - const next = this.peek(); - if ( - next.type !== 'punct' || - (next.value !== '*' && next.value !== '/') - ) { - break; - } - } while (MUL_BP >= minBp); + } while (this.isPunct('*', '/')); left = mkProduct(factors); continue; } @@ -255,22 +265,19 @@ const PREFIX = { ), ident: (p, t) => { - const nxt = p.peek(); - if (nxt.type === 'punct' && nxt.value === '(') { - p.next(); + if (p.matchPunct('(')) { if (OPAQUE_ARG_FUNCTIONS.has(t.value.toLowerCase())) { return parseOpaqueCall(p, t.value); } /** @type {Node[]} */ const args = []; - if (!isPunct(p.peek(), ')')) { + if (!p.isPunct(')')) { args.push(p.parseExpr(0)); - while (isPunct(p.peek(), ',')) { - p.next(); + while (p.matchPunct(',')) { args.push(p.parseExpr(0)); } } - p.expect('punct', ')'); + p.expectPunct(')'); return call(t.value, args); } const kw = foldCalcKeyword(t.value); @@ -282,7 +289,7 @@ const PREFIX = { '(': (p) => { const e = p.parseExpr(0); - p.expect('punct', ')'); + p.expectPunct(')'); return e.type === 'Sum' ? { ...e, grouped: true } : e; }, diff --git a/src/lib/serialize.js b/src/lib/serialize.js index 83a6cdd..ad5cc54 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -3,6 +3,8 @@ // arithmetic operator. A Sum inside a Product is the only place parens // are ever required on valid canonical input. +import { num, dim } from './node.js'; + /** * @typedef {import('./node.js').Node} Node * @typedef {import('./node.js').Sum} Sum @@ -105,14 +107,11 @@ function serialize(node, opts = {}) { node.terms.length > 1 && displaySign(node.terms[0]).sign === -1 ) { - const body = /** @type {Sum} */ ({ - type: 'Sum', - terms: node.terms.map((t) => ({ - sign: /** @type {1 | -1} */ (-t.sign), - node: t.node, - })), - }); - return `${calcName}(-(${serializeExpr(body, prec)}))`; + const invertedTerms = node.terms.map((t) => ({ + sign: /** @type {1 | -1} */ (-t.sign), + node: t.node, + })); + return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`; } if ( @@ -182,27 +181,27 @@ function displaySign(term) { if (node.type === 'Num' && Number.isFinite(node.value) && node.value < 0) { return { sign: /** @type {1 | -1} */ (-sign), - magnitude: { type: 'Num', value: -node.value }, + magnitude: num(-node.value), }; } if (node.type === 'Dim' && Number.isFinite(node.value) && node.value < 0) { return { sign: /** @type {1 | -1} */ (-sign), - magnitude: { type: 'Dim', value: -node.value, unit: node.unit }, + magnitude: dim(-node.value, node.unit), }; } return { sign, magnitude: node }; } /** - * @param {Sum} sum + * @param {import('./node.js').SumTerm[]} terms * @param {number | false} prec * @return {string} */ -function serializeSum(sum, prec) { +function serializeSumTerms(terms, prec) { let out = ''; - for (const [i, t] of sum.terms.entries()) { - const { sign, magnitude } = displaySign(t); + for (let i = 0; i < terms.length; i++) { + const { sign, magnitude } = displaySign(terms[i]); if (i === 0) { if (magnitude.type === 'Sum' && magnitude.grouped) { const body = `(${serializeExpr(magnitude, prec)})`; @@ -225,6 +224,15 @@ function serializeSum(sum, prec) { return out; } +/** + * @param {Sum} sum + * @param {number | false} prec + * @return {string} + */ +function serializeSum(sum, prec) { + return serializeSumTerms(sum.terms, prec); +} + /** * Fold a leading negation into a finite leading Num if there is one * (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`. @@ -249,11 +257,8 @@ function serializeLeadingNeg(node, prec) { const negatedFactors = negatedValue === 1 ? rest - : [ - { exponent: 1, node: { type: 'Num', value: negatedValue } }, - ...rest, - ]; - return serializeProduct({ type: 'Product', factors: negatedFactors }, prec); + : [{ exponent: 1, node: num(negatedValue) }, ...rest]; + return serializeFactors(negatedFactors, prec); } const body = serializeExpr(node, prec); return node.type === 'Sum' || node.type === 'Product' @@ -262,13 +267,14 @@ function serializeLeadingNeg(node, prec) { } /** - * @param {Product} product + * @param {ProductFactor[]} factors * @param {number | false} prec * @return {string} */ -function serializeProduct(product, prec) { +function serializeFactors(factors, prec) { let out = ''; - for (const [i, f] of product.factors.entries()) { + for (let i = 0; i < factors.length; i++) { + const f = factors[i]; let body = serializeExpr(f.node, prec); // A Sum factor needs parens: `a * (b + c)`. Flat canonical form means // this is the only place parens are required. @@ -285,4 +291,13 @@ function serializeProduct(product, prec) { return out; } +/** + * @param {Product} product + * @param {number | false} prec + * @return {string} + */ +function serializeProduct(product, prec) { + return serializeFactors(product.factors, prec); +} + export { serialize }; diff --git a/src/lib/simplify/bucket.js b/src/lib/simplify/bucket.js index 05315fa..b7c418a 100644 --- a/src/lib/simplify/bucket.js +++ b/src/lib/simplify/bucket.js @@ -21,7 +21,7 @@ import { convert } from '../convertUnits.js'; * @return {UnitBucket[]} */ function mergeConvertibleBuckets(buckets) { - const ordered = [...buckets].sort((a, b) => a.order - b.order); + const ordered = buckets.sort((a, b) => a.order - b.order); /** @type {Set} */ const merged = new Set(); /** @type {UnitBucket[]} */ const out = []; for (const b of ordered) { diff --git a/src/lib/simplify/call.js b/src/lib/simplify/call.js index 8d51b6b..5b9bb40 100644 --- a/src/lib/simplify/call.js +++ b/src/lib/simplify/call.js @@ -26,7 +26,7 @@ import { call } from '../node.js'; // Bare CSS math functions with implemented simplification semantics, keyed // by lowercase name. calc() and its vendor-prefixed forms are handled // separately as wrappers in simplifyCall. This map is the single source of -// truth for both dispatch and `isSupportedMathFunction`. +// truth for dispatch, `isSupportedMathFunction`, and `QUICK_MATH_TEST`. /** @type {Map} */ const MATH_SIMPLIFIERS = new Map([ ['min', simplifyMinMax], @@ -51,6 +51,29 @@ const MATH_SIMPLIFIERS = new Map([ ['exp', (_name, args) => simplifyExp(args)], ]); +const mathFnNames = [...MATH_SIMPLIFIERS.keys()].sort( + (a, b) => b.length - a.length +); + +const QUICK_MATH_TEST = new RegExp( + `(?:-(?:webkit|moz)-)?(?:calc|${mathFnNames.join('|')})\\(`, + 'i' +); + +/** + * Fast check to determine whether a CSS component value could contain + * a supported calculation or math function call (or an escape sequence + * that could decode to one). + * + * @param {string} value + * @return {boolean} + */ +function hasPotentialMathFunction(value) { + return ( + value.includes('(') && (QUICK_MATH_TEST.test(value) || value.includes('\\')) + ); +} + /** * Whether a bare CSS math function has an implemented simplifier. * @@ -91,4 +114,9 @@ function simplifyCall(node, simplify) { return call(node.name, args); } -export { isSupportedMathFunction, simplifyCall }; +export { + isSupportedMathFunction, + simplifyCall, + hasPotentialMathFunction, + QUICK_MATH_TEST, +}; diff --git a/src/lib/simplify/clamp.js b/src/lib/simplify/clamp.js index 56fd3f0..da0148a 100644 --- a/src/lib/simplify/clamp.js +++ b/src/lib/simplify/clamp.js @@ -1,5 +1,6 @@ -import { num, dim, call } from '../node.js'; -import { foldConstArgs } from './fold.js'; +import { call } from '../node.js'; +import { foldConstArgs, foldResult } from './fold.js'; +import { simplifyMinMax } from './min-max.js'; /** @typedef {import('../node.js').Node} Node */ @@ -9,16 +10,37 @@ import { foldConstArgs } from './fold.js'; */ function simplifyClamp(args) { if (args.length === 3) { + const minNone = isNone(args[0]); + const maxNone = isNone(args[2]); + + if (minNone && maxNone) { + return args[1]; + } + if (minNone) { + return simplifyMinMax('min', [args[1], args[2]]); + } + if (maxNone) { + return simplifyMinMax('max', [args[0], args[1]]); + } + const fold = foldConstArgs(args); if (fold !== null) { const [lo, v, hi] = /** @type {[number, number, number]} */ (fold.values); // Spec §10.8: clamp(MIN, VAL, MAX) = max(MIN, min(VAL, MAX)). The // outer max(MIN, …) means MIN wins when MIN > MAX — not MAX. const clamped = Math.max(lo, Math.min(v, hi)); - return fold.unit === '' ? num(clamped) : dim(clamped, fold.unit); + return foldResult(fold, clamped); } } return call('clamp', args); } +/** + * @param {Node} node + * @return {boolean} + */ +function isNone(node) { + return node.type === 'Ident' && node.name.toLowerCase() === 'none'; +} + export { simplifyClamp }; diff --git a/src/lib/simplify/fold.js b/src/lib/simplify/fold.js index d9ee553..a3c1b59 100644 --- a/src/lib/simplify/fold.js +++ b/src/lib/simplify/fold.js @@ -1,8 +1,21 @@ +import { num, dim } from '../node.js'; import { baseOf, convert } from '../convertUnits.js'; /** @typedef {import('../node.js').Node} Node */ +/** @typedef {import('../node.js').Num} Num */ +/** @typedef {import('../node.js').Dim} Dim */ /** @typedef {import('../convertUnits.js').BaseType} BaseType */ +/** + * Construct a Num or Dim node from a folded result. + * @param {{ unit: string }} fold + * @param {number} value + * @return {Num | Dim} + */ +function foldResult(fold, value) { + return fold.unit === '' ? num(value) : dim(value, fold.unit); +} + /** * @param {Node[]} args * @return {{ values: number[], unit: string } | null} @@ -62,4 +75,4 @@ function foldDimArgs(args, unit, base) { return { values, unit }; } -export { foldConstArgs }; +export { foldConstArgs, foldResult }; diff --git a/src/lib/simplify/hypot.js b/src/lib/simplify/hypot.js index c1fcdf2..6d427c2 100644 --- a/src/lib/simplify/hypot.js +++ b/src/lib/simplify/hypot.js @@ -1,7 +1,7 @@ // §10.5 — hypot. Empty args return null from foldConstArgs naturally. -import { num, dim, call } from '../node.js'; -import { foldConstArgs } from './fold.js'; +import { call } from '../node.js'; +import { foldConstArgs, foldResult } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -16,7 +16,7 @@ function simplifyHypot(args) { } const sumSq = fold.values.reduce((acc, v) => acc + v * v, 0); const result = Math.sqrt(sumSq); - return fold.unit === '' ? num(result) : dim(result, fold.unit); + return foldResult(fold, result); } export { simplifyHypot }; diff --git a/src/lib/simplify/min-max.js b/src/lib/simplify/min-max.js index 5120120..701d27c 100644 --- a/src/lib/simplify/min-max.js +++ b/src/lib/simplify/min-max.js @@ -1,5 +1,5 @@ -import { num, dim, call } from '../node.js'; -import { foldConstArgs } from './fold.js'; +import { call } from '../node.js'; +import { foldConstArgs, foldResult } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -13,7 +13,7 @@ function simplifyMinMax(name, args) { if (fold !== null) { const fn = name.toLowerCase() === 'min' ? Math.min : Math.max; const value = fn(...fold.values); - return fold.unit === '' ? num(value) : dim(value, fold.unit); + return foldResult(fold, value); } return call(name, args); } diff --git a/src/lib/simplify/mod-rem.js b/src/lib/simplify/mod-rem.js index 0b67b85..7745d96 100644 --- a/src/lib/simplify/mod-rem.js +++ b/src/lib/simplify/mod-rem.js @@ -1,5 +1,5 @@ -import { num, dim, call } from '../node.js'; -import { foldConstArgs } from './fold.js'; +import { num, call } from '../node.js'; +import { foldConstArgs, foldResult } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -23,7 +23,7 @@ function simplifyModRem(name, args) { if (Number.isNaN(result)) { return num(Number.NaN); } - return fold.unit === '' ? num(result) : dim(result, fold.unit); + return foldResult(fold, result); } /** diff --git a/src/lib/simplify/round.js b/src/lib/simplify/round.js index a1fad3f..22b7176 100644 --- a/src/lib/simplify/round.js +++ b/src/lib/simplify/round.js @@ -1,5 +1,5 @@ -import { num, dim, ident, call } from '../node.js'; -import { foldConstArgs } from './fold.js'; +import { num, ident, call } from '../node.js'; +import { foldConstArgs, foldResult } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -58,14 +58,14 @@ function simplifyRound(args) { } else { result = a < 0 || Object.is(a, -0) ? -0 : 0; } - return fold.unit === '' ? num(result) : dim(result, fold.unit); + return foldResult(fold, result); } const result = applyRound(strategy, a, b); if (Number.isNaN(result)) { return num(Number.NaN); } - return fold.unit === '' ? num(result) : dim(result, fold.unit); + return foldResult(fold, result); } /** diff --git a/src/lib/tokenizer.js b/src/lib/tokenizer.js index 53de326..2affecb 100644 --- a/src/lib/tokenizer.js +++ b/src/lib/tokenizer.js @@ -26,6 +26,32 @@ function tokenize(input) { return tokenizeTokens(tokenizeCss({ css: input }), input.length); } +/** + * CSS absorbs leading signs (`-5px` is one token); the parser expects + * punct sign + unsigned numeric, so split them back out. + * @param {Token[]} tokens + * @param {string} raw + * @param {string | undefined} unit + * @param {number} pos + * @param {boolean} ws + * @return {void} + */ +function pushNumeric(tokens, raw, unit, pos, ws) { + let value = /** @type {RegExpExecArray} */ (NUMERIC_RAW.exec(raw))[0]; + const sign = value[0]; + if (sign === '+' || sign === '-') { + tokens.push({ type: 'punct', value: sign, pos, ws }); + value = value.slice(1); + pos += 1; + ws = false; + } + if (unit === undefined) { + tokens.push({ type: 'number', value, pos, ws }); + } else { + tokens.push({ type: 'dimension', value, unit, pos, ws }); + } +} + /** * Convert a slice of an existing CSS token stream into the token subset used * by the calculation parser. Token positions remain relative to the original @@ -33,52 +59,38 @@ function tokenize(input) { * * @param {import('@csstools/css-tokenizer').CSSToken[]} cssTokens * @param {number} eofPosition + * @param {number} [start] + * @param {number} [end] * @return {Token[]} */ -function tokenizeTokens(cssTokens, eofPosition) { +function tokenizeTokens( + cssTokens, + eofPosition, + start = 0, + end = cssTokens.length +) { /** @type {Token[]} */ const tokens = []; let ws = true; - // CSS absorbs leading signs (`-5px` is one token); the parser expects - // punct sign + unsigned numeric, so split them back out. - /** - * @param {string} raw - * @param {string | undefined} unit - * @param {number} pos - * @return {void} - */ - function pushNumeric(raw, unit, pos) { - let value = /** @type {RegExpExecArray} */ (NUMERIC_RAW.exec(raw))[0]; - const sign = value[0]; - if (sign === '+' || sign === '-') { - tokens.push({ type: 'punct', value: sign, pos, ws }); - value = value.slice(1); - pos += 1; - ws = false; - } - if (unit === undefined) { - tokens.push({ type: 'number', value, pos, ws }); - } else { - tokens.push({ type: 'dimension', value, unit, pos, ws }); - } - ws = false; - } - - for (const t of cssTokens) { + for (let i = start; i < end; i++) { + const t = cssTokens[i]; switch (t[0]) { case CssType.Whitespace: case CssType.Comment: ws = true; continue; case CssType.Number: - pushNumeric(t[1], undefined, t[2]); + pushNumeric(tokens, t[1], undefined, t[2], ws); + ws = false; continue; case CssType.Dimension: - pushNumeric(t[1], t[4].unit, t[2]); + pushNumeric(tokens, t[1], t[4].unit, t[2], ws); + ws = false; continue; case CssType.Percentage: - pushNumeric(t[1], '%', t[2]); + pushNumeric(tokens, t[1], '%', t[2], ws); + ws = false; continue; case CssType.Ident: tokens.push({ type: 'ident', value: t[4].value, pos: t[2], ws }); diff --git a/src/reduce.js b/src/reduce.js new file mode 100644 index 0000000..46f3419 --- /dev/null +++ b/src/reduce.js @@ -0,0 +1,165 @@ +// CSS component-value reducer. This module deliberately has no PostCSS +// dependency so it can also be used for individual declaration values, +// at-rule parameters, or selector text. +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, + hasPotentialMathFunction, + QUICK_MATH_TEST, +} 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} ReduceCalcOptions + * @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} ResolvedReduceCalcOptions */ + +/** + * 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 {ResolvedReduceCalcOptions} 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, end, sliceStart, sliceEnd)) + ); + 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; +} + +/** + * Simplify every supported CSS math function in a component-value string. + * Text outside those functions is preserved byte-for-byte. + * + * @param {string} value + * @param {ReduceCalcOptions} [opts] + * @return {string} + */ +function reduceCalc(value, opts) { + if (!hasPotentialMathFunction(value)) { + return value; + } + + /** @type {ResolvedReduceCalcOptions} */ + const options = { precision: 5, warnWhenCannotResolve: false, ...opts }; + const tokens = cssTokenize({ css: value }); + /** @type {Replacement[]} */ + const replacements = []; + walkTokens(0, undefined, { options, value, tokens, replacements }, true); + + if (replacements.length === 0) { + return value; + } + + let output = ''; + let lastIndex = 0; + for (const replacement of replacements) { + 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); + } + output += value.slice(lastIndex, replacement.start) + text; + lastIndex = replacement.end; + } + output += value.slice(lastIndex); + return output; +} + +export { QUICK_MATH_TEST, hasPotentialMathFunction }; +export default reduceCalc; diff --git a/test/conformance/csstools.test.mjs b/test/conformance/csstools.test.mjs index c04477c..37332f9 100644 --- a/test/conformance/csstools.test.mjs +++ b/test/conformance/csstools.test.mjs @@ -155,14 +155,13 @@ describe('csstools clamp', () => { }); // --- basic/none-in-clamp.mjs (subset) ------------------------------------ -// clamp(none, ...) uses keyword `none` as unbounded. csstools supports this; -// we treat `none` as an opaque ident, so these preserve. -test('csstools none-in-clamp: none as lower bound preserved', () => { - assert.equal(out('clamp(none, 10px, 20px)'), 'clamp(none, 10px, 20px)'); +// clamp(none, ...) uses keyword `none` as unbounded per §10.5.3. +test('csstools none-in-clamp: none as lower bound folds via min()', () => { + assert.equal(out('clamp(none, 10px, 20px)'), '10px'); }); -test('csstools none-in-clamp: none as upper bound preserved', () => { - assert.equal(out('clamp(1px, 10px, none)'), 'clamp(1px, 10px, none)'); +test('csstools none-in-clamp: none as upper bound folds via max()', () => { + assert.equal(out('clamp(1px, 10px, none)'), '10px'); }); // --- wpt/invalid.mjs (subset our tokenizer/parser rejects) --------------- diff --git a/test/integration/package-exports.test.mjs b/test/integration/package-exports.test.mjs new file mode 100644 index 0000000..f2b648f --- /dev/null +++ b/test/integration/package-exports.test.mjs @@ -0,0 +1,85 @@ +import { spawnSync } from 'node:child_process'; +import { + mkdtemp, + mkdir, + readdir, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { join, resolve } from 'node:path'; + +function run(command, args, options) { + const result = spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); + assert.equal( + result.status, + 0, + `${command} ${args.join(' ')} failed:\n${result.stdout}${result.stderr}` + ); +} + +test('packed package exposes the standalone reducer with usable types', async () => { + // Keeping this below the project lets the unpacked package resolve this + // checkout's already-installed runtime dependencies as an installed + // consumer would. + const fixture = await mkdtemp(join(process.cwd(), '.postcss-calc-package-')); + try { + run('pnpm', ['pack', '--pack-destination', fixture], { + cwd: process.cwd(), + }); + + const archive = join( + fixture, + (await readdir(fixture)).find((name) => name.endsWith('.tgz')) + ); + const modules = join(fixture, 'node_modules'); + await mkdir(modules); + run('tar', ['-xzf', archive, '-C', modules]); + await rename(join(modules, 'package'), join(modules, 'postcss-calc')); + + const runtime = join(fixture, 'runtime.mjs'); + await writeFile( + runtime, + [ + "import reduceCalc from 'postcss-calc/reduce';", + "if (reduceCalc('calc(1px + 2px)') !== '3px') throw new Error('reducer failed');", + ].join('\n') + ); + run(process.execPath, [runtime], { cwd: fixture }); + + const types = join(fixture, 'consumer.ts'); + await writeFile( + types, + [ + "import reduceCalc, { type ReduceCalcOptions } from 'postcss-calc/reduce';", + 'const options: ReduceCalcOptions = { precision: false };', + "const reduced: string = reduceCalc('calc(1px + 2px)', options);", + 'void reduced;', + ].join('\n') + ); + run( + process.execPath, + [ + resolve('node_modules/typescript/bin/tsc'), + '--noEmit', + '--ignoreConfig', + '--module', + 'nodenext', + '--moduleResolution', + 'nodenext', + '--target', + 'es2022', + '--strict', + types, + ], + { cwd: fixture } + ); + } finally { + await rm(fixture, { force: true, recursive: true }); + } +}); diff --git a/test/property/algebraic-laws.test.mjs b/test/property/algebraic-laws.test.mjs index f43ab92..b5fcd08 100644 --- a/test/property/algebraic-laws.test.mjs +++ b/test/property/algebraic-laws.test.mjs @@ -11,19 +11,12 @@ import { describe, test } from 'node:test'; import fc from 'fast-check'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; +import { call, num, dim, ident } from '../../src/lib/node.js'; const NUM_RUNS = 500; const out = (n) => serialize(simplify(n), { precision: 10 }); -const call = (name, args) => ({ type: 'Call', name, args }); - -const num = (v) => ({ type: 'Num', value: v }); - -const dim = (v, u) => ({ type: 'Dim', value: v, unit: u }); - -const ident = (n) => ({ type: 'Ident', name: n }); - // Finite, non-zero numeric leaf — domain for most laws. const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); @@ -42,7 +35,7 @@ const finiteDim = fc fc.integer({ min: -1000, max: 1000 }), fc.constantFrom(...SAME_UNIT_DIMS) ) - .map(([v, u]) => ({ type: 'Dim', value: v, unit: u })); + .map(([v, u]) => dim(v, u)); const finiteLeaf = fc.oneof(finiteNum, finiteDim); diff --git a/test/unit/parser.test.mjs b/test/unit/parser.test.mjs index d2b4a12..c07f3f1 100644 --- a/test/unit/parser.test.mjs +++ b/test/unit/parser.test.mjs @@ -291,4 +291,10 @@ describe('parser: Anchor', () => { test('parse: empty input throws', () => { assert.throws(() => parse(tokenize('')), /Unexpected token/); }); + + test('parser: punctuation helper methods match and expect punctuation tokens', () => { + assert.equal(ast('1 + 2 + 3 + 4'), '(+ 1 2 3 4)'); + assert.equal(ast('2 * 3 * 4 * 5'), '(* 2 3 4 5)'); + assert.equal(ast('min(1, 2, 3)'), '(min 1 2 3)'); + }); }); diff --git a/test/unit/plugin.test.mjs b/test/unit/plugin.test.mjs index d443c1e..18dd54f 100644 --- a/test/unit/plugin.test.mjs +++ b/test/unit/plugin.test.mjs @@ -324,6 +324,13 @@ describe('plugin: bare math functions', () => { assert.equal(css, 'a{ width: 5px }'); }); + test('plugin: simplifies clamp() with none keyword', async () => { + const { css } = await process( + 'a{ a: clamp(none, 10px, 20px); b: clamp(10px, 20px, none); c: clamp(none, 10px, none); d: clamp(none, var(--x), 20px) }' + ); + assert.equal(css, 'a{ a: 10px; b: 20px; c: 10px; d: min(var(--x), 20px) }'); + }); + test('plugin: simplifies bare math functions case-insensitively', async () => { const { css } = await process('a{ width: MIN(1px, 2px) }'); assert.equal(css, 'a{ width: 1px }'); diff --git a/test/unit/reduceCalc.test.mjs b/test/unit/reduceCalc.test.mjs index 3033ee4..7017ef0 100644 --- a/test/unit/reduceCalc.test.mjs +++ b/test/unit/reduceCalc.test.mjs @@ -2,7 +2,10 @@ // 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'; +import reduceCalc, { + hasPotentialMathFunction, + QUICK_MATH_TEST, +} from 'postcss-calc/reduce'; function reduceWithWarnings(value, opts = {}) { const warnings = []; @@ -62,6 +65,75 @@ describe('reduceCalc: basic pipeline', () => { ); }); + test('reduceCalc: non-math functions are preserved quickly without change', () => { + assert.equal(reduceCalc('rgb(255, 0, 0)'), 'rgb(255, 0, 0)'); + assert.equal(reduceCalc('translate(10px, 20px)'), 'translate(10px, 20px)'); + assert.equal(reduceCalc('var(--my-color)'), 'var(--my-color)'); + }); + + test('reduceCalc: hasPotentialMathFunction detects all supported math functions and escapes', () => { + const supported = [ + 'calc', + '-webkit-calc', + '-moz-calc', + 'min', + 'max', + 'clamp', + 'abs', + 'sign', + 'mod', + 'rem', + 'round', + 'sin', + 'cos', + 'tan', + 'asin', + 'acos', + 'atan', + 'atan2', + 'pow', + 'sqrt', + 'hypot', + 'log', + 'exp', + ]; + for (const fn of supported) { + assert.equal( + hasPotentialMathFunction(`${fn}(10px)`), + true, + `Expected ${fn}() to be detected` + ); + assert.equal( + hasPotentialMathFunction(`${fn.toUpperCase()}(10PX)`), + true, + `Expected ${fn.toUpperCase()}() to be detected` + ); + assert.equal( + QUICK_MATH_TEST.test(`${fn}(10px)`), + true, + `Expected QUICK_MATH_TEST to match ${fn}()` + ); + } + + // Escapes bypass regex check + assert.equal(hasPotentialMathFunction('\\63 alc(10px)'), true); + assert.equal(hasPotentialMathFunction('foo\\(bar'), true); + + // Negative cases + assert.equal(hasPotentialMathFunction('10px'), false); + assert.equal(hasPotentialMathFunction('red'), false); + assert.equal(hasPotentialMathFunction('rgb(255, 0, 0)'), false); + assert.equal(hasPotentialMathFunction('var(--my-var)'), false); + assert.equal(hasPotentialMathFunction('translate(10px, 20px)'), false); + }); + + test('reduceCalc: nested calculations inside non-math functions are reduced', () => { + assert.equal( + reduceCalc('translate(calc(10px + 20px), calc(5px * 2))'), + 'translate(30px, 10px)' + ); + }); + test('reduceCalc: transformations are idempotent', () => { const opts = { warnWhenCannotResolve: true }; assertIdempotent('calc(1px + 2px) calc(2px + 3px)', opts); @@ -279,6 +351,16 @@ describe('reduceCalc: bare math functions', () => { assert.equal(reduceCalc('clamp(0px, 5px, 10px)'), '5px'); }); + test('reduceCalc: simplifies clamp() with none keyword', () => { + assert.equal(reduceCalc('clamp(none, 10px, 20px)'), '10px'); + assert.equal(reduceCalc('clamp(10px, 20px, none)'), '20px'); + assert.equal(reduceCalc('clamp(none, 10px, none)'), '10px'); + assert.equal( + reduceCalc('clamp(none, var(--x), 20px)'), + 'min(var(--x), 20px)' + ); + }); + test('reduceCalc: simplifies bare math functions case-insensitively', () => { assert.equal(reduceCalc('MIN(1px, 2px)'), '1px'); }); diff --git a/test/unit/serialize.test.mjs b/test/unit/serialize.test.mjs index cb8f78e..c5ee6bf 100644 --- a/test/unit/serialize.test.mjs +++ b/test/unit/serialize.test.mjs @@ -1,15 +1,12 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { serialize } from '../../src/lib/serialize.js'; -import { mkSum, mkProduct } from '../../src/lib/node.js'; +import { num, dim, mkSum, mkProduct } from '../../src/lib/node.js'; // Direct serialize() tests — build canonical AST nodes by hand to pin // output shape without depending on the parser/simplify. // Signed-leaf canonical form: negatives live directly in the Num/Dim // value — no wrapper needed. -const num = (v) => ({ type: 'Num', value: v }); - -const dim = (v, u) => ({ type: 'Dim', value: v, unit: u }); describe('serialize: Single Number', () => { test('serialize: single number — no calc wrapper', () => { diff --git a/test/unit/simplify/clamp.test.mjs b/test/unit/simplify/clamp.test.mjs index 3aaf00f..f00f27e 100644 --- a/test/unit/simplify/clamp.test.mjs +++ b/test/unit/simplify/clamp.test.mjs @@ -16,4 +16,34 @@ describe('simplify: Clamp Folds', () => { assert.equal(out('clamp(0px, -2px, -1px)'), '0px'); assert.equal(out('clamp(128, 64, 3)'), '128'); }); + + test('simplify: clamp with none keyword (spec §10.5.3)', () => { + // clamp(none, VAL, MAX) is equivalent to min(VAL, MAX) + assert.equal(out('clamp(none, 10px, 20px)'), '10px'); + assert.equal(out('clamp(none, 50px, 20px)'), '20px'); + assert.equal(out('clamp(none, var(--x), 20px)'), 'min(var(--x), 20px)'); + assert.equal(out('clamp(none, 10px, var(--x))'), 'min(10px, var(--x))'); + assert.equal(out('clamp(none, 10px, 20deg)'), 'min(10px, 20deg)'); + + // clamp(MIN, VAL, none) is equivalent to max(MIN, VAL) + assert.equal(out('clamp(10px, 20px, none)'), '20px'); + assert.equal(out('clamp(30px, 20px, none)'), '30px'); + assert.equal(out('clamp(10px, var(--x), none)'), 'max(10px, var(--x))'); + assert.equal(out('clamp(var(--x), 20px, none)'), 'max(var(--x), 20px)'); + assert.equal(out('clamp(10deg, 20px, none)'), 'max(10deg, 20px)'); + + // clamp(none, VAL, none) is equivalent to calc(VAL) (or bare VAL) + assert.equal(out('clamp(none, 10px, none)'), '10px'); + assert.equal(out('clamp(none, var(--x), none)'), 'var(--x)'); + assert.equal(out('clamp(none, 10px + 20px, none)'), '30px'); + assert.equal( + out('clamp(none, var(--a) + var(--b), none)'), + 'calc(var(--a) + var(--b))' + ); + + // Case insensitivity + assert.equal(out('clamp(NONE, 10px, 20px)'), '10px'); + assert.equal(out('clamp(10px, 20px, NONE)'), '20px'); + assert.equal(out('clamp(None, 10px, None)'), '10px'); + }); }); diff --git a/test/unit/tokenizer.test.mjs b/test/unit/tokenizer.test.mjs index d943bac..edbcfd5 100644 --- a/test/unit/tokenizer.test.mjs +++ b/test/unit/tokenizer.test.mjs @@ -275,3 +275,14 @@ test('tok: converts a source-relative slice from a shared CSS token stream', () ] ); }); + +test('tok: converts token stream using start and end indices without slicing', () => { + const css = 'prefix calc(/* gap */-2P\\58 + +3px) suffix'; + const cssTokens = tokenizeCss({ css }); + const start = cssTokens.findIndex((token) => token[0] === CssType.Comment); + const end = cssTokens.findIndex((token) => token[0] === CssType.CloseParen); + const sliced = tokenizeTokens(cssTokens.slice(start, end), cssTokens[end][2]); + const bounded = tokenizeTokens(cssTokens, cssTokens[end][2], start, end); + + assert.deepEqual(bounded, sliced); +}); diff --git a/tsconfig.json b/tsconfig.json index 24a1707..d229377 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "forceConsistentCasingInFileNames": true, "strict": true }, - "include": ["src/index.js", "src/lib/**/*.js"] + "include": ["src/index.js", "src/reduce.js", "src/lib/**/*.js"] } diff --git a/types/index.d.ts b/types/index.d.ts index 4d15261..43935dd 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,16 +1,3 @@ -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; @@ -22,25 +9,6 @@ export type PostCssCalcOptions = { onParseError?: (error: Error, input: string) => void; }; export type ResolvedOptions = Required> & Pick; -export type TransformContext = { - options: ResolvedTransformOptions; - value: string; - tokens: import('@csstools/css-tokenizer').CSSToken[]; - replacements: Replacement[]; -}; -export type Replacement = { - start: number; - end: number; - node: import('./lib/node.js').Node; - 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} @@ -51,4 +19,4 @@ declare namespace pluginCreator { } declare const _default: import('postcss').PluginCreator; export default _default; -export { transformValue as reduceCalc, pluginCreator as 'module.exports' }; +export { pluginCreator as 'module.exports' }; diff --git a/types/lib/parser.d.ts b/types/lib/parser.d.ts index b96ef3c..5e2f5e2 100644 --- a/types/lib/parser.d.ts +++ b/types/lib/parser.d.ts @@ -21,6 +21,22 @@ declare class Parser { * @return {Token} */ expect(type: TokenType, value?: string): Token; + /** + * @param {string} value + * @param {string} [value2] + * @return {boolean} + */ + isPunct(value: string, value2?: string): boolean; + /** + * @param {string} value + * @return {boolean} + */ + matchPunct(value: string): boolean; + /** + * @param {string} value + * @return {Token} + */ + expectPunct(value: string): Token; /** * @param {number} [minBp] * @return {Node} diff --git a/types/lib/simplify/call.d.ts b/types/lib/simplify/call.d.ts index 8e94a20..f7e2542 100644 --- a/types/lib/simplify/call.d.ts +++ b/types/lib/simplify/call.d.ts @@ -1,6 +1,16 @@ export type Node = import('../node.js').Node; export type SimplifyFn = import('../simplify.js').SimplifyFn; export type MathSimplifier = (name: string, args: Node[]) => Node; +declare const QUICK_MATH_TEST: RegExp; +/** + * Fast check to determine whether a CSS component value could contain + * a supported calculation or math function call (or an escape sequence + * that could decode to one). + * + * @param {string} value + * @return {boolean} + */ +declare function hasPotentialMathFunction(value: string): boolean; /** * Whether a bare CSS math function has an implemented simplifier. * @@ -16,4 +26,4 @@ declare function isSupportedMathFunction(name: string): boolean; declare function simplifyCall(node: Extract, simplify: SimplifyFn): Node; -export { isSupportedMathFunction, simplifyCall }; +export { isSupportedMathFunction, simplifyCall, hasPotentialMathFunction, QUICK_MATH_TEST, }; diff --git a/types/lib/simplify/fold.d.ts b/types/lib/simplify/fold.d.ts index 7cf2327..df7b560 100644 --- a/types/lib/simplify/fold.d.ts +++ b/types/lib/simplify/fold.d.ts @@ -1,7 +1,20 @@ export type Node = import('../node.js').Node; +export type Num = import('../node.js').Num; +export type Dim = import('../node.js').Dim; export type BaseType = import('../convertUnits.js').BaseType; /** @typedef {import('../node.js').Node} Node */ +/** @typedef {import('../node.js').Num} Num */ +/** @typedef {import('../node.js').Dim} Dim */ /** @typedef {import('../convertUnits.js').BaseType} BaseType */ +/** + * Construct a Num or Dim node from a folded result. + * @param {{ unit: string }} fold + * @param {number} value + * @return {Num | Dim} + */ +declare function foldResult(fold: { + unit: string; +}, value: number): Num | Dim; /** * @param {Node[]} args * @return {{ values: number[], unit: string } | null} @@ -10,4 +23,4 @@ declare function foldConstArgs(args: Node[]): { values: number[]; unit: string; } | null; -export { foldConstArgs }; +export { foldConstArgs, foldResult }; diff --git a/types/lib/tokenizer.d.ts b/types/lib/tokenizer.d.ts index 32a9c85..02f6836 100644 --- a/types/lib/tokenizer.d.ts +++ b/types/lib/tokenizer.d.ts @@ -24,7 +24,9 @@ declare function tokenize(input: string): Token[]; * * @param {import('@csstools/css-tokenizer').CSSToken[]} cssTokens * @param {number} eofPosition + * @param {number} [start] + * @param {number} [end] * @return {Token[]} */ -declare function tokenizeTokens(cssTokens: import('@csstools/css-tokenizer').CSSToken[], eofPosition: number): Token[]; +declare function tokenizeTokens(cssTokens: import('@csstools/css-tokenizer').CSSToken[], eofPosition: number, start?: number, end?: number): Token[]; export { tokenize, tokenizeTokens }; diff --git a/types/lib/type.d.ts b/types/lib/type.d.ts deleted file mode 100644 index 2892da6..0000000 --- a/types/lib/type.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type BaseType = - | 'length' - | 'angle' - | 'time' - | 'frequency' - | 'resolution' - | 'flex' - | 'percentage'; -/** - * @param {string} unit - * @return {BaseType | null} - */ -export function baseOf(unit: string): BaseType | null; -/** - * Convert a value within a single conversion family. Returns null when - * either unit is missing from the table (em/rem/vw need runtime context) - * or when the units belong to different base types. - * @param {number} value - * @param {string} from - * @param {string} to - * @return {number | null} - */ -export function convert(value: number, from: string, to: string): number | null; diff --git a/types/reduce.d.ts b/types/reduce.d.ts new file mode 100644 index 0000000..a1304f8 --- /dev/null +++ b/types/reduce.d.ts @@ -0,0 +1,38 @@ +import { hasPotentialMathFunction, QUICK_MATH_TEST } from './lib/simplify/call.js'; +export type ReduceCalcOptions = { + 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 ResolvedReduceCalcOptions = Required> & Pick; +export type TransformContext = { + options: ResolvedReduceCalcOptions; + value: string; + tokens: import('@csstools/css-tokenizer').CSSToken[]; + replacements: Replacement[]; +}; +export type Replacement = { + start: number; + end: number; + node: import('./lib/node.js').Node; + calcName: string; + matchedName: string; +}; +/** + * Simplify every supported CSS math function in a component-value string. + * Text outside those functions is preserved byte-for-byte. + * + * @param {string} value + * @param {ReduceCalcOptions} [opts] + * @return {string} + */ +declare function reduceCalc(value: string, opts?: ReduceCalcOptions): string; +export { QUICK_MATH_TEST, hasPotentialMathFunction }; +export default reduceCalc;