diff --git a/packages/core/src/config/functions.ts b/packages/core/src/config/functions.ts index 6af92b10f..565e8c2c2 100644 --- a/packages/core/src/config/functions.ts +++ b/packages/core/src/config/functions.ts @@ -33,7 +33,7 @@ const functions = { oklab: { unit: '' }, oklch: { unit: '' }, 'light-dark': { unit: '' }, - clamp: { unit: '' }, + clamp: { transformer: ['core.math-fn', { name: 'clamp' }] }, repeat: { unit: '' }, 'linear-gradient': {}, 'radial-gradient': {}, diff --git a/packages/core/src/function-transformers/core.math-fn.ts b/packages/core/src/function-transformers/core.math-fn.ts new file mode 100644 index 000000000..0d2eff24e --- /dev/null +++ b/packages/core/src/function-transformers/core.math-fn.ts @@ -0,0 +1,61 @@ +import { SyntaxRule } from '../syntax-rule' + +/** + * Transformer for CSS math comparison functions (clamp/min/max). + * + * Each top-level comma-separated argument is itself a `` per + * CSS Values Module Level 4. Browsers reject bare arithmetic like + * `clamp(1.5rem, 2vw+1rem, 2.25rem)`; the middle arg must be wrapped in + * `calc()`. This transformer auto-wraps any argument that contains an + * infix arithmetic operator and isn't already a calc()/min()/max()/clamp()/var(). + * + * The function name is passed via the transformer data tuple, e.g. + * `clamp: { transformer: ['core.math-fn', { name: 'clamp' }] }`. + */ +export default function coreMathFn( + this: SyntaxRule, + value: string, + _bypassVariableNames: string[], + data?: { name: string } +) { + const fnName = data?.name ?? '' + + // Walk top-level commas, preserving the exact original substring of each + // argument (including its surrounding whitespace) and the comma separators. + const tokens: string[] = [] + let depth = 0 + let start = 0 + for (let i = 0; i < value.length; i++) { + const c = value[i] + if (c === '(') depth++ + else if (c === ')') depth-- + else if (c === ',' && depth === 0) { + tokens.push(value.slice(start, i)) + tokens.push(',') + start = i + 1 + } + } + tokens.push(value.slice(start)) + + const out = tokens.map((part) => { + if (part === ',') return part + const trimmed = part.trim() + if (!trimmed) return part + if (/^(?:calc|clamp|min|max|var)\(/.test(trimmed)) return part + if (/[a-zA-Z0-9%)]\s*[+\-*/]\s*[a-zA-Z0-9(.]/.test(trimmed)) { + // CSS calc() requires whitespace around `+` and `-` (per the spec); + // `*` and `/` allow no whitespace. Normalize unary signs first + // (e.g. leading `-1rem`) before inserting spaces. + const spaced = trimmed.replace( + /([a-zA-Z0-9%)])\s*([+\-])\s*([a-zA-Z0-9(.])/g, + '$1 $2 $3' + ) + const leadingWs = part.match(/^\s*/)![0] + const trailingWs = part.match(/\s*$/)![0] + return `${leadingWs}calc(${spaced})${trailingWs}` + } + return part + }) + + return `${fnName}(${out.join('')})` +} diff --git a/packages/core/src/function-transformers/index.ts b/packages/core/src/function-transformers/index.ts index ea0cec1ad..735b1cec1 100644 --- a/packages/core/src/function-transformers/index.ts +++ b/packages/core/src/function-transformers/index.ts @@ -1,8 +1,10 @@ import coreCalc from './core.calc' +import coreMathFn from './core.math-fn' import coreVariable from './core.variable' const functionTransformers = { 'core.calc': coreCalc, + 'core.math-fn': coreMathFn, 'core.variable': coreVariable, } diff --git a/packages/core/tests/issue-358-clamp.test.ts b/packages/core/tests/issue-358-clamp.test.ts new file mode 100644 index 000000000..a53e3ee5c --- /dev/null +++ b/packages/core/tests/issue-358-clamp.test.ts @@ -0,0 +1,47 @@ +import { describe, test, expect } from 'vitest' +import { MasterCSS, config as defaultConfig } from '../src' + +describe('issue #358: clamp() with bare arithmetic in middle arg', () => { + test('font-size:clamp(1.5rem,2vw+1rem,2.25rem) auto-wraps the arithmetic arg in calc()', () => { + const css = new MasterCSS(undefined, defaultConfig) + const rule = css.create('font-size:clamp(1.5rem,2vw+1rem,2.25rem)') + expect(rule).toBeDefined() + // calc() must have whitespace around + and - per CSS spec, otherwise the browser rejects it. + expect(rule?.text).toMatch(/font-size:clamp\(1\.5rem,\s*calc\(2vw \+ 1rem\),\s*2\.25rem\)/) + }) + + test('clamp() handles whitespace in input correctly', () => { + const css = new MasterCSS(undefined, defaultConfig) + const rule = css.create('font-size:clamp(1rem, 2vw + 1rem, 3rem)') + expect(rule?.text).toMatch(/clamp\(\s*1rem\s*,\s*calc\(2vw\s*\+\s*1rem\)\s*,\s*3rem\s*\)/) + }) + + test('clamp() args that are already calc() are not double-wrapped', () => { + const css = new MasterCSS(undefined, defaultConfig) + const rule = css.create('font-size:clamp(1rem,calc(2vw+1rem),3rem)') + expect(rule?.text).toMatch(/clamp\(1rem,calc\(2vw\s*\+\s*1rem\),3rem\)/) + expect(rule?.text).not.toContain('calc(calc(') + }) + + test('clamp() with negative leading values still parses correctly', () => { + const css = new MasterCSS(undefined, defaultConfig) + const rule = css.create('font-size:clamp(-1rem,2vw,3rem)') + // Single token with leading minus is a sign, not arithmetic — no calc wrapping. + expect(rule?.text).toContain('clamp(-1rem,2vw,3rem)') + }) + + test('clamp() with calc() wrapper still works (regression)', () => { + const css = new MasterCSS(undefined, defaultConfig) + const rule = css.create('font-size:clamp(1.5rem,calc(2vw+1rem),2.25rem)') + expect(rule).toBeDefined() + expect(rule?.text).toContain('font-size') + expect(rule?.text).toContain('clamp(') + }) + + test('plain clamp() without arithmetic still works', () => { + const css = new MasterCSS(undefined, defaultConfig) + const rule = css.create('font-size:clamp(1rem,2vw,3rem)') + expect(rule).toBeDefined() + expect(rule?.text).toContain('clamp(1rem,2vw,3rem)') + }) +})