Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/config/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {},
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/function-transformers/core.math-fn.ts
Original file line number Diff line number Diff line change
@@ -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 `<calc-sum>` 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('')})`
}
2 changes: 2 additions & 0 deletions packages/core/src/function-transformers/index.ts
Original file line number Diff line number Diff line change
@@ -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,
}

Expand Down
47 changes: 47 additions & 0 deletions packages/core/tests/issue-358-clamp.test.ts
Original file line number Diff line number Diff line change
@@ -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)')
})
})
Loading