diff --git a/package.json b/package.json index 5ec1248..e309c5c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "devEngines": { "packageManager": { "name": "pnpm", - "version": "11.21.0" + "version": "11.22.0" } }, "devDependencies": { @@ -53,8 +53,8 @@ "typescript": "~7.0.2" }, "dependencies": { - "@csstools/css-tokenizer": "^4.0.0", - "postcss-value-parser": "^4.2.0" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" }, "peerDependencies": { "postcss": "^8.5.25" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be4ba1a..0fba150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,52 +7,52 @@ importers: configDependencies: {} packageManagerDependencies: '@pnpm/exe': - specifier: 11.21.0 - version: 11.21.0 + specifier: 11.22.0 + version: 11.22.0 pnpm: - specifier: 11.21.0 - version: 11.21.0 + specifier: 11.22.0 + version: 11.22.0 packages: - '@pnpm/exe@11.21.0': - resolution: {integrity: sha512-zawQxIewH1od72HhlmXWq3No6XyuWn+nMvQ9BjWWGNBVskmS+RDlTu7ey2ruL650PbbyuCATYSal1DaXFKBdcw==} + '@pnpm/exe@11.22.0': + resolution: {integrity: sha512-B1SGeKm+v9pX9YkzMmrnO2FbgBd8TDwzZ3jSj6J6ThxdyGvI4TsOfMeHARW4Wb25visPpmMDfIXrU76EZdJM4g==} hasBin: true - '@pnpm/linux-arm64@11.21.0': - resolution: {integrity: sha512-gOSfQKr6kZjEwHyoRwMt9qrqQ9sqbZmUm2hbgJJG8bp0ZR9YkQ4BZV2k4qlQA2jtsmHV1u1MwiaLcuK7DauvBg==} + '@pnpm/linux-arm64@11.22.0': + resolution: {integrity: sha512-xzzn3jYG9QaiFZPaHcWM3yX4Fm9UGz5E42mzpbvUEvXYf5O9fwNolenOhGLpRl2qS5u35fjZSvDZbWlOwHMcug==} cpu: [arm64] os: [linux] - '@pnpm/linux-x64@11.21.0': - resolution: {integrity: sha512-X+kBR8yscKyhhElO+WLrb6sFbl/3Ow70B+6fqZUYI8T8wtmlCw5GtcPXVBJPDJcLN5joe227h7lyCCZo4tdKdw==} + '@pnpm/linux-x64@11.22.0': + resolution: {integrity: sha512-isvaPctGinbsM2hsTtRsMarN8Sr5QhXDTNmn8Xv9Lp1PjincCvH2RBbhpo+xYIOxzgls1dtQSdgoORUbfRVALQ==} cpu: [x64] os: [linux] - '@pnpm/linuxstatic-arm64@11.21.0': - resolution: {integrity: sha512-IUJfAclH0b3QxaHuQuVxXQIzEkDtTm0C+G3tgG0ET5tDRGc7wH7eU0GEM75ojHOwzqv7s0y00xPVVCIsxUM4Nw==} + '@pnpm/linuxstatic-arm64@11.22.0': + resolution: {integrity: sha512-i4J+AQWW0T3JBdXaLsvxu8ZmMG1HLS6JZQESdOR9uBv8Zumb4yg8GYT83eWRLacr6ngVdhEBiC3W4eOG64MFbA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/linuxstatic-x64@11.21.0': - resolution: {integrity: sha512-6Y2u+AfOUuTqWgTCpFhySL8HAcONDucCFixKle9tWoW7bm8RF0+fwQBRWNWGdx+Toau07wZ1LNZPqN8gpgeBDQ==} + '@pnpm/linuxstatic-x64@11.22.0': + resolution: {integrity: sha512-QYzk8jhSuSbVthW/OxEOhU3f3zhjpw4HqgedrlwbVNb7btCWn5del2Hb5PsYYka2PbmKq5EtF5IzYN8Yp1CfxQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/macos-arm64@11.21.0': - resolution: {integrity: sha512-sLMGvVJXWdhFAouY2icjeZ2VCFmyPPZvvtHkfj1oeCWGppsbWcP5cExSw9yU1Uw7ALwrV0I2lRZv33yWYfDtcQ==} + '@pnpm/macos-arm64@11.22.0': + resolution: {integrity: sha512-Io8Axk5kutPgMuAfOg1QGj3J0/KpLvH5iiPcmp7up8D4q7BNWT02ndQo3RyGWqrlkf5nwspM41GFpWTShrZ4Aw==} cpu: [arm64] os: [darwin] - '@pnpm/win-arm64@11.21.0': - resolution: {integrity: sha512-79Nc+YI5B2ddH5MQD2YITL/PKnmXdcQKwmwx0HaD4QnsCco8DFaTho242e5sd9QfXKxYRvB9AOnuqIV4VjhAAw==} + '@pnpm/win-arm64@11.22.0': + resolution: {integrity: sha512-QgaRuKGQKov7xW2utPCgDT83fn/PU5cD6HFgib48oTslz7wv26E89d4jMCxL4GRmEL2YCfkYuj5ITj1oVWg5WQ==} cpu: [arm64] os: [win32] - '@pnpm/win-x64@11.21.0': - resolution: {integrity: sha512-zT3TufmVOroWPrzXTPPPgYvIsTZIsK13kjpmgXlICyEFrhd16RFLoTWFhP+8UqHXpTNmVbtTHzg+fEVYy9rlEQ==} + '@pnpm/win-x64@11.22.0': + resolution: {integrity: sha512-iWYsiSwpgxqur+TwsnSoPdOQaEfd4ygB84mb5tJUOgim6PHr1xhKJBndaQzH+At/WkLxLdYN+K8dYc4M7naezg==} cpu: [x64] os: [win32] @@ -116,45 +116,45 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - pnpm@11.21.0: - resolution: {integrity: sha512-UhcFvOaJkk6scvWjWHEi82JonvZXHlW6gAdv1jfBETLs/62ib61Op5xIW/3b/T1aKlsFgFp36JPeceyKbMo7sQ==} + pnpm@11.22.0: + resolution: {integrity: sha512-H/hwxMYTPf2I+yr8Rt0T1H8JyXlLQ4xv20fKmMrzvBY4HuC+k6CRuOOCTPAfiJ9G19niCRD7C+GrD7W6qA3WIQ==} engines: {node: '>=22.13'} hasBin: true snapshots: - '@pnpm/exe@11.21.0': + '@pnpm/exe@11.22.0': dependencies: '@reflink/reflink': 0.1.19 detect-libc: 2.1.2 optionalDependencies: - '@pnpm/linux-arm64': 11.21.0 - '@pnpm/linux-x64': 11.21.0 - '@pnpm/linuxstatic-arm64': 11.21.0 - '@pnpm/linuxstatic-x64': 11.21.0 - '@pnpm/macos-arm64': 11.21.0 - '@pnpm/win-arm64': 11.21.0 - '@pnpm/win-x64': 11.21.0 + '@pnpm/linux-arm64': 11.22.0 + '@pnpm/linux-x64': 11.22.0 + '@pnpm/linuxstatic-arm64': 11.22.0 + '@pnpm/linuxstatic-x64': 11.22.0 + '@pnpm/macos-arm64': 11.22.0 + '@pnpm/win-arm64': 11.22.0 + '@pnpm/win-x64': 11.22.0 - '@pnpm/linux-arm64@11.21.0': + '@pnpm/linux-arm64@11.22.0': optional: true - '@pnpm/linux-x64@11.21.0': + '@pnpm/linux-x64@11.22.0': optional: true - '@pnpm/linuxstatic-arm64@11.21.0': + '@pnpm/linuxstatic-arm64@11.22.0': optional: true - '@pnpm/linuxstatic-x64@11.21.0': + '@pnpm/linuxstatic-x64@11.22.0': optional: true - '@pnpm/macos-arm64@11.21.0': + '@pnpm/macos-arm64@11.22.0': optional: true - '@pnpm/win-arm64@11.21.0': + '@pnpm/win-arm64@11.22.0': optional: true - '@pnpm/win-x64@11.21.0': + '@pnpm/win-x64@11.22.0': optional: true '@reflink/reflink-darwin-arm64@0.1.19': @@ -194,7 +194,7 @@ snapshots: detect-libc@2.1.2: {} - pnpm@11.21.0: {} + pnpm@11.22.0: {} --- lockfileVersion: '9.0' @@ -207,12 +207,12 @@ importers: .: dependencies: + '@csstools/css-parser-algorithms': + specifier: ^4.0.0 + version: 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': specifier: ^4.0.0 version: 4.0.0 - postcss-value-parser: - specifier: ^4.2.0 - version: 4.2.0 devDependencies: '@csstools/css-calc': specifier: ^3.3.0 @@ -666,9 +666,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -939,8 +936,6 @@ snapshots: picocolors@1.1.1: {} - postcss-value-parser@4.2.0: {} - postcss@8.5.26: dependencies: nanoid: 3.3.18 diff --git a/src/index.js b/src/index.js index 93fbdad..aed35b8 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,25 @@ // 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 valueParser from 'postcss-value-parser'; +import { tokenize as cssTokenize } from '@csstools/css-tokenizer'; +import { + isFunctionNode, + isSimpleBlockNode, + parseListOfComponentValues, +} from '@csstools/css-parser-algorithms'; import { tokenize } 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'; +// The outer walk is deliberately forgiving: it only needs to locate calc()/ +// math-function boundaries in otherwise arbitrary (and possibly non-standard) +// CSS values, so parse errors from the outer tokenizer/parser are swallowed. +// Genuine syntax problems inside a matched call are +// caught below via our own tokenize/parse/simplify pipeline. +const NOOP_PARSE_ERROR = { onParseError: () => {} }; + const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i; /** @@ -22,6 +34,80 @@ const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i; /** @typedef {Required> & Pick} ResolvedOptions */ +/** + * Fields threaded unchanged through the recursive `transformList` walk. + * `value` is the original full property text, used only for the + * warnWhenCannotResolve message. + * + * @typedef {object} TransformContext + * @property {ResolvedOptions} options + * @property {import('postcss').Result} result + * @property {import('postcss').ChildNode} item + * @property {string} value + */ + +/** + * Walks a list of component values in place, replacing matched calc()/math + * function nodes with their simplified form. Unlike the library's generic + * `walk` helper, this recurses manually so a matched node's own (stale, + * pre-simplification) children are never independently re-visited. + * + * @param {import('@csstools/css-parser-algorithms').ComponentValue[]} list + * @param {TransformContext} ctx + * @return {void} + */ +function transformList(list, ctx) { + for (let i = 0; i < list.length; i++) { + const node = list[i]; + if (!isFunctionNode(node)) { + if (isSimpleBlockNode(node)) { + transformList(node.value, ctx); + } + continue; + } + + const name = node.getName(); + const isCalc = MATCH_CALC.test(name); + const isMath = !isCalc && isSupportedMathFunction(name); + if (!isCalc && !isMath) { + transformList(node.value, ctx); + continue; + } + + // calc(): feed the body. Bare math: feed the whole call. + const inner = node.value.map((child) => child.toString()).join(''); + const contents = isCalc ? inner : `${name}(${inner})`; + try { + const simplified = simplify(parse(tokenize(contents))); + const str = serialize(simplified, { + precision: ctx.options.precision, + calcName: isCalc ? name : 'calc', // preserve vendor prefix on calc() + }); + + if (ctx.options.warnWhenCannotResolve && str.startsWith(`${name}(`)) { + ctx.result.warn('Could not reduce expression: ' + ctx.value, { + plugin: 'postcss-calc', + node: ctx.item, + }); + } + + const replacement = parseListOfComponentValues( + cssTokenize({ css: str }), + NOOP_PARSE_ERROR + ); + list.splice(i, 1, ...replacement); + i += replacement.length - 1; + } 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 }); + } + } + } +} + /** * @param {string} value * @param {ResolvedOptions} options @@ -30,49 +116,40 @@ const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i; * @return {string} */ function transformValue(value, options, result, item) { - return valueParser(value) - .walk((node) => { - if (node.type !== 'function') { - return; - } - const isCalc = MATCH_CALC.test(node.value); - const isMath = !isCalc && isSupportedMathFunction(node.value); - if (!isCalc && !isMath) { - return; - } + const componentValues = parseListOfComponentValues( + cssTokenize({ css: value }), + NOOP_PARSE_ERROR + ); - // calc(): feed the body. Bare math: feed the whole call. - const inner = valueParser.stringify(node.nodes); - const contents = isCalc ? inner : `${node.value}(${inner})`; - try { - const simplified = simplify(parse(tokenize(contents))); - const str = serialize(simplified, { - precision: options.precision, - calcName: isCalc ? node.value : 'calc', // preserve vendor prefix on calc() - }); + transformList(componentValues, { options, result, item, value }); - if (options.warnWhenCannotResolve && str.startsWith(`${node.value}(`)) { - result.warn('Could not reduce expression: ' + value, { - plugin: 'postcss-calc', - node: item, - }); - } + return componentValues.map((node) => node.toString()).join(''); +} - // Re-tag as `word` so value-parser emits `str` verbatim instead of - // re-wrapping it as `name(...)`. Cast widens the `'function'` literal. - /** @type {{type: string}} */ (node).type = 'word'; - node.value = str; - } catch (error) { - const err = error instanceof Error ? error : new Error('Error'); - if (options.onParseError) { - options.onParseError(err, contents); - } else { - result.warn(err.message, { node: item }); - } - } - return false; - }) - .toString(); +/** + * Runs `transformValue` over one text property of a decl/atrule/rule node + * and, per `options.preserve`, either updates it in place or inserts a + * clone carrying the transformed value ahead of the untouched original. + * `setProp` closes over the property name and the concrete node type at + * each call site, since `Declaration`/`AtRule`/`Rule` don't share a typed + * "text property" name to index generically. + * + * @param {import('postcss').ChildNode} node + * @param {string} current + * @param {(target: import('postcss').ChildNode, value: string) => void} setProp + * @param {ResolvedOptions} options + * @param {import('postcss').Result} result + * @return {void} + */ +function applyTransform(node, current, setProp, options, result) { + const next = transformValue(current, options, result, node); + if (options.preserve && current !== next && node.parent) { + const clone = node.clone(); + setProp(clone, next); + node.parent.insertBefore(node, clone); + } else { + setProp(node, next); + } } /** @@ -99,36 +176,39 @@ function pluginCreator(opts) { OnceExit(css, { result }) { css.walk((node) => { if (node.type === 'decl') { - const next = transformValue(node.value, options, result, node); - if (options.preserve && node.value !== next && node.parent) { - const clone = node.clone(); - clone.value = next; - node.parent.insertBefore(node, clone); - } else { - node.value = next; - } + applyTransform( + node, + node.value, + (n, v) => { + /** @type {import('postcss').Declaration} */ (n).value = v; + }, + options, + result + ); } if (node.type === 'atrule' && options.mediaQueries) { - const next = transformValue(node.params, options, result, node); - if (options.preserve && node.params !== next && node.parent) { - const clone = node.clone(); - clone.params = next; - node.parent.insertBefore(node, clone); - } else { - node.params = next; - } + applyTransform( + node, + node.params, + (n, v) => { + /** @type {import('postcss').AtRule} */ (n).params = v; + }, + options, + result + ); } if (node.type === 'rule' && options.selectors) { // Reduces `:nth-child(calc(...))` via the function walk. calc() in a // quoted attribute value is a literal match, so it's left untouched. - const next = transformValue(node.selector, options, result, node); - if (options.preserve && node.selector !== next && node.parent) { - const clone = node.clone(); - clone.selector = next; - node.parent.insertBefore(node, clone); - } else { - node.selector = next; - } + applyTransform( + node, + node.selector, + (n, v) => { + /** @type {import('postcss').Rule} */ (n).selector = v; + }, + options, + result + ); } }); }, diff --git a/src/lib/parser.js b/src/lib/parser.js index 8ffdde0..097a923 100644 --- a/src/lib/parser.js +++ b/src/lib/parser.js @@ -1,7 +1,7 @@ // Pratt parser. +/- emit Sum nodes; */÷ emit Product nodes. node.js // constructors flatten and normalize on construction, while parenthesized // sums retain a grouping marker for the opaque-subtraction invariant. -import { mkSum, mkProduct, negate } from './node.js'; +import { mkSum, mkProduct, negate, ident, call } from './node.js'; /** * @typedef {import('./tokenizer.js').Token} Token @@ -174,7 +174,7 @@ function parseOpaqueCall(p, name) { const flush = () => { const trimmed = buf.trim(); if (trimmed) { - args.push({ type: 'Ident', name: trimmed }); + args.push(ident(trimmed)); } buf = ''; }; @@ -191,7 +191,7 @@ function parseOpaqueCall(p, name) { if (depth === 0) { p.next(); flush(); - return { type: 'Call', name, args }; + return call(name, args); } } else if (tk.value === ',' && depth === 1) { p.next(); @@ -254,13 +254,13 @@ const PREFIX = { } } p.expect('punct', ')'); - return { type: 'Call', name: t.value, args }; + return call(t.value, args); } const kw = foldCalcKeyword(t.value); if (kw) { return kw; } - return { type: 'Ident', name: t.value }; + return ident(t.value); }, '(': (p) => { diff --git a/src/lib/simplify/abs.js b/src/lib/simplify/abs.js index 65138e2..0a03118 100644 --- a/src/lib/simplify/abs.js +++ b/src/lib/simplify/abs.js @@ -1,4 +1,4 @@ -import { num, dim } from '../node.js'; +import { num, dim, call } from '../node.js'; /** @typedef {import('../node.js').Node} Node */ @@ -8,7 +8,7 @@ import { num, dim } from '../node.js'; */ function simplifyAbs(args) { if (args.length !== 1) { - return { type: 'Call', name: 'abs', args }; + return call('abs', args); } const a = args[0]; if (a.type === 'Num') { @@ -17,7 +17,7 @@ function simplifyAbs(args) { if (a.type === 'Dim' && a.unit !== '%') { return dim(Math.abs(a.value), a.unit); } - return { type: 'Call', name: 'abs', args: [a] }; + return call('abs', [a]); } export { simplifyAbs }; diff --git a/src/lib/simplify/atan2.js b/src/lib/simplify/atan2.js index 3b7fe2c..d459f7a 100644 --- a/src/lib/simplify/atan2.js +++ b/src/lib/simplify/atan2.js @@ -1,7 +1,7 @@ /* §10.4 — atan2. foldConstArgs already rejects percentages (property- context-resolved) and enforces shared base + static convertibility. */ -import { num, dim } from '../node.js'; +import { num, dim, call } from '../node.js'; import { foldConstArgs } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -12,11 +12,11 @@ import { foldConstArgs } from './fold.js'; */ function simplifyAtan2(args) { if (args.length !== 2) { - return { type: 'Call', name: 'atan2', args }; + return call('atan2', args); } const fold = foldConstArgs(args); if (fold === null) { - return { type: 'Call', name: 'atan2', args }; + return call('atan2', args); } const [y, x] = /** @type {[number, number]} */ (fold.values); const radians = Math.atan2(y, x); diff --git a/src/lib/simplify/call.js b/src/lib/simplify/call.js index b701d18..8d51b6b 100644 --- a/src/lib/simplify/call.js +++ b/src/lib/simplify/call.js @@ -16,6 +16,8 @@ import { simplifyExp } from './exp.js'; import { simplifyLog } from './log.js'; import { simplifyHypot } from './hypot.js'; +import { call } from '../node.js'; + /** @typedef {import('../node.js').Node} Node */ /** @typedef {import('../simplify.js').SimplifyFn} SimplifyFn */ @@ -86,7 +88,7 @@ function simplifyCall(node, simplify) { ); } - return { type: 'Call', name: node.name, args }; + return call(node.name, args); } export { isSupportedMathFunction, simplifyCall }; diff --git a/src/lib/simplify/clamp.js b/src/lib/simplify/clamp.js index ee51514..56fd3f0 100644 --- a/src/lib/simplify/clamp.js +++ b/src/lib/simplify/clamp.js @@ -1,4 +1,4 @@ -import { num, dim } from '../node.js'; +import { num, dim, call } from '../node.js'; import { foldConstArgs } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -18,7 +18,7 @@ function simplifyClamp(args) { return fold.unit === '' ? num(clamped) : dim(clamped, fold.unit); } } - return { type: 'Call', name: 'clamp', args }; + return call('clamp', args); } export { simplifyClamp }; diff --git a/src/lib/simplify/exp.js b/src/lib/simplify/exp.js index c7e6817..0cacbc3 100644 --- a/src/lib/simplify/exp.js +++ b/src/lib/simplify/exp.js @@ -1,4 +1,4 @@ -import { num } from '../node.js'; +import { num, call } from '../node.js'; /** @typedef {import('../node.js').Node} Node */ @@ -8,7 +8,7 @@ import { num } from '../node.js'; */ function simplifyExp(args) { if (args.length !== 1 || args[0].type !== 'Num') { - return { type: 'Call', name: 'exp', args }; + return call('exp', args); } return num(Math.exp(args[0].value)); } diff --git a/src/lib/simplify/hypot.js b/src/lib/simplify/hypot.js index 2e13c46..c1fcdf2 100644 --- a/src/lib/simplify/hypot.js +++ b/src/lib/simplify/hypot.js @@ -1,6 +1,6 @@ // §10.5 — hypot. Empty args return null from foldConstArgs naturally. -import { num, dim } from '../node.js'; +import { num, dim, call } from '../node.js'; import { foldConstArgs } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -12,7 +12,7 @@ import { foldConstArgs } from './fold.js'; function simplifyHypot(args) { const fold = foldConstArgs(args); if (fold === null) { - return { type: 'Call', name: 'hypot', args }; + return call('hypot', args); } const sumSq = fold.values.reduce((acc, v) => acc + v * v, 0); const result = Math.sqrt(sumSq); diff --git a/src/lib/simplify/inverse-trig.js b/src/lib/simplify/inverse-trig.js index 0b9677b..a1374ed 100644 --- a/src/lib/simplify/inverse-trig.js +++ b/src/lib/simplify/inverse-trig.js @@ -1,6 +1,6 @@ // §10.4 — asin/acos/atan. Bare in, in degrees out. -import { num, dim } from '../node.js'; +import { num, dim, call } from '../node.js'; /** @typedef {import('../node.js').Node} Node */ @@ -17,11 +17,11 @@ const INVERSE_TRIG_OPS = /** @type {const} */ ({ */ function simplifyInverseTrig(name, args) { if (args.length !== 1) { - return { type: 'Call', name, args }; + return call(name, args); } const a = args[0]; if (a.type !== 'Num') { - return { type: 'Call', name, args }; + return call(name, args); } const radians = INVERSE_TRIG_OPS[name](a.value); if (isNaN(radians)) { diff --git a/src/lib/simplify/log.js b/src/lib/simplify/log.js index fad084b..ef36258 100644 --- a/src/lib/simplify/log.js +++ b/src/lib/simplify/log.js @@ -1,4 +1,4 @@ -import { num } from '../node.js'; +import { num, call } from '../node.js'; /** @typedef {import('../node.js').Node} Node */ @@ -13,7 +13,7 @@ function simplifyLog(args) { if (args.length === 2 && args[0].type === 'Num' && args[1].type === 'Num') { return num(Math.log(args[0].value) / Math.log(args[1].value)); } - return { type: 'Call', name: 'log', args }; + return call('log', args); } export { simplifyLog }; diff --git a/src/lib/simplify/min-max.js b/src/lib/simplify/min-max.js index 080781e..5120120 100644 --- a/src/lib/simplify/min-max.js +++ b/src/lib/simplify/min-max.js @@ -1,4 +1,4 @@ -import { num, dim } from '../node.js'; +import { num, dim, call } from '../node.js'; import { foldConstArgs } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -15,7 +15,7 @@ function simplifyMinMax(name, args) { const value = fn(...fold.values); return fold.unit === '' ? num(value) : dim(value, fold.unit); } - return { type: 'Call', name, args }; + return call(name, args); } export { simplifyMinMax }; diff --git a/src/lib/simplify/mod-rem.js b/src/lib/simplify/mod-rem.js index 0d82f18..d6a9306 100644 --- a/src/lib/simplify/mod-rem.js +++ b/src/lib/simplify/mod-rem.js @@ -1,4 +1,4 @@ -import { num, dim } from '../node.js'; +import { num, dim, call } from '../node.js'; import { foldConstArgs } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -10,11 +10,11 @@ import { foldConstArgs } from './fold.js'; */ function simplifyModRem(name, args) { if (args.length !== 2) { - return { type: 'Call', name, args }; + return call(name, args); } const fold = foldConstArgs(args); if (fold === null) { - return { type: 'Call', name, args }; + return call(name, args); } const [a, b] = /** @type {[number, number]} */ (fold.values); const result = applyModRem(name, a, b); diff --git a/src/lib/simplify/pow.js b/src/lib/simplify/pow.js index 12e85a4..073da21 100644 --- a/src/lib/simplify/pow.js +++ b/src/lib/simplify/pow.js @@ -1,6 +1,6 @@ // §10.5 — pow is -only. -import { num } from '../node.js'; +import { num, call } from '../node.js'; /** @typedef {import('../node.js').Node} Node */ @@ -10,7 +10,7 @@ import { num } from '../node.js'; */ function simplifyPow(args) { if (args.length !== 2 || args[0].type !== 'Num' || args[1].type !== 'Num') { - return { type: 'Call', name: 'pow', args }; + return call('pow', args); } return num(Math.pow(args[0].value, args[1].value)); } diff --git a/src/lib/simplify/round.js b/src/lib/simplify/round.js index e86d22b..f3c4565 100644 --- a/src/lib/simplify/round.js +++ b/src/lib/simplify/round.js @@ -1,4 +1,4 @@ -import { num, dim } from '../node.js'; +import { num, dim, ident, call } from '../node.js'; import { foldConstArgs } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -18,21 +18,15 @@ function simplifyRound(args) { const n = first.name.toLowerCase(); if (!ROUND_STRATEGIES.has(n)) { // Unrecognized strategy ident — opaque rather than guessing intent. - return { type: 'Call', name: 'round', args }; + return call('round', args); } strategy = /** @type {RoundStrategy} */ (n); rest = args.slice(1); } /** @type {() => Node} */ - const passthrough = () => ({ - type: 'Call', - name: 'round', - args: - strategy === 'nearest' - ? rest - : [{ type: 'Ident', name: strategy }, ...rest], - }); + const passthrough = () => + call('round', strategy === 'nearest' ? rest : [ident(strategy), ...rest]); // B omitted: defaults to 1 when A is ; else opaque. const argsForFold = argsForRoundFold(rest); diff --git a/src/lib/simplify/sign.js b/src/lib/simplify/sign.js index cfaf7d4..3752111 100644 --- a/src/lib/simplify/sign.js +++ b/src/lib/simplify/sign.js @@ -1,4 +1,4 @@ -import { num } from '../node.js'; +import { num, call } from '../node.js'; /** @typedef {import('../node.js').Node} Node */ @@ -8,7 +8,7 @@ import { num } from '../node.js'; */ function simplifySign(args) { if (args.length !== 1) { - return { type: 'Call', name: 'sign', args }; + return call('sign', args); } const a = args[0]; if (a.type === 'Num') { @@ -18,7 +18,7 @@ function simplifySign(args) { if (a.type === 'Dim' && a.unit !== '%') { return num(Math.sign(a.value)); } - return { type: 'Call', name: 'sign', args: [a] }; + return call('sign', [a]); } export { simplifySign }; diff --git a/src/lib/simplify/sqrt.js b/src/lib/simplify/sqrt.js index a3febc2..a4b28a5 100644 --- a/src/lib/simplify/sqrt.js +++ b/src/lib/simplify/sqrt.js @@ -1,4 +1,4 @@ -import { num } from '../node.js'; +import { num, call } from '../node.js'; /** @typedef {import('../node.js').Node} Node */ /** @@ -7,7 +7,7 @@ import { num } from '../node.js'; */ function simplifySqrt(args) { if (args.length !== 1 || args[0].type !== 'Num') { - return { type: 'Call', name: 'sqrt', args }; + return call('sqrt', args); } return num(Math.sqrt(args[0].value)); } diff --git a/src/lib/simplify/trig.js b/src/lib/simplify/trig.js index 81e3649..0242112 100644 --- a/src/lib/simplify/trig.js +++ b/src/lib/simplify/trig.js @@ -1,6 +1,6 @@ // §10.4 — sin/cos/tan. is radians; dim is converted. -import { num } from '../node.js'; +import { num, call } from '../node.js'; import { baseOf, convert } from '../convertUnits.js'; /** @typedef {import('../node.js').Node} Node */ @@ -18,7 +18,7 @@ const TRIG_OPS = /** @type {const} */ ({ */ function simplifyTrig(name, args) { if (args.length !== 1) { - return { type: 'Call', name, args }; + return call(name, args); } const a = args[0]; /** @type {number | null} */ let radians = null; @@ -36,7 +36,7 @@ function simplifyTrig(name, args) { } } if (radians === null) { - return { type: 'Call', name, args }; + return call(name, args); } return num(TRIG_OPS[name](radians)); } diff --git a/test/unit/plugin.test.mjs b/test/unit/plugin.test.mjs index 514a5df..6bc9e43 100644 --- a/test/unit/plugin.test.mjs +++ b/test/unit/plugin.test.mjs @@ -173,13 +173,24 @@ test('plugin: onParseError catches errors in @media params (mediaQueries: true)' assert.equal(errors.length, 1); }); test('plugin: selectors:true reduces calc() in selector text', async () => { - // Walking the selector with value-parser surfaces calc() function nodes - // that aren't safely buried in attribute-value strings. + // Walking the selector surfaces calc() function nodes that aren't safely + // buried in attribute-value strings. const { css } = await process('a:nth-child(calc(1 + 2)) { b: c }', { selectors: true, }); assert.match(css, /:nth-child\(3\)/); }); +test('plugin: selectors + preserve clones the rule', async () => { + // Same shape as the mediaQueries + preserve case: the simplified rule + // appears first, followed by the original (preserve clones into the + // parent before the live node). + const { css } = await process('a:nth-child(calc(1 + 2)) { b: c }', { + selectors: true, + preserve: true, + }); + assert.match(css, /:nth-child\(3\)/); + assert.match(css, /:nth-child\(calc\(1 \+ 2\)\)/); +}); test('plugin: onParseError does not fire for fully-resolved inputs', async () => { const errors = []; await process('a{b:calc(1px + 2px)}', { @@ -227,3 +238,31 @@ test('plugin: leaves opaque-arg bare min() preserved', async () => { const { css } = await process('a{ width: min(1px, var(--x)) }'); assert.equal(css, 'a{ width: min(1px, var(--x)) }'); }); +// --- Outer-walk round-trip ------ +// The outer traversal runs the whole declaration/selector/param text +// through @csstools/css-tokenizer + css-parser-algorithms (a strict, +// spec-compliant parser). These tests pin down that content having +// nothing to do with calc() still round-trips byte-for-byte through the +// tokenizer. +test('plugin: IE backslash hack survives the outer walk untouched', async () => { + const { css } = await process('a{width:calc(1px + 2px)\\9}'); + assert.equal(css, 'a{width:3px\\9}'); +}); +test('plugin: escaped content value survives the outer walk untouched', async () => { + const { css } = await process('a{content:"\\e901"}'); + assert.equal(css, 'a{content:"\\e901"}'); +}); +test('plugin: unicode-range descriptor survives the outer walk untouched', async () => { + const { css } = await process('@font-face{unicode-range:U+0025-00FF}'); + assert.equal(css, '@font-face{unicode-range:U+0025-00FF}'); +}); +test('plugin: url() contents are opaque, even when they look like calc()', async () => { + const { css } = await process('a{background:url(calc(1px).png)}'); + assert.equal(css, 'a{background:url(calc(1px).png)}'); +}); +test('plugin: grid line names survive alongside a reduced calc() term', async () => { + const { css } = await process( + 'a{grid-template-columns:[full-start] calc(1px + 2px) [full-end]}' + ); + assert.equal(css, 'a{grid-template-columns:[full-start] 3px [full-end]}'); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 65310c8..d901194 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -10,6 +10,12 @@ export type PluginOptions = { onParseError?: (error: Error, input: string) => void; }; export type ResolvedOptions = Required> & Pick; +export type TransformContext = { + options: ResolvedOptions; + result: import('postcss').Result; + item: import('postcss').ChildNode; + value: string; +}; /** * @param {PluginOptions} [opts] * @return {import('postcss').Plugin}