Skip to content

Commit 36d341d

Browse files
committed
chore: improve arbitrary values
1 parent e6caa08 commit 36d341d

7 files changed

Lines changed: 378 additions & 28 deletions

File tree

packages/crosswind/src/parser.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,23 +73,48 @@ function needsArbitraryBrackets(value: string): boolean {
7373
/**
7474
* Convert underscores to spaces in arbitrary values (Tailwind convention).
7575
* e.g. grid-cols-[120px_1fr_200px] → "120px 1fr 200px"
76-
* Preserves underscores inside url(), var(), and other CSS functions.
76+
* max-h-[calc(100vh_-_120px)] → "calc(100vh - 120px)"
77+
*
78+
* Only `url(...)` preserves underscores — URLs legitimately contain them and
79+
* auto-converting would corrupt paths. Every other CSS function (calc, clamp,
80+
* min, max, var, linear-gradient, etc.) uses spaces around operators, so
81+
* underscores there are always stand-ins for spaces.
82+
*
83+
* To emit a literal underscore in a non-url value, escape it as `\_`.
7784
*/
7885
function convertArbitraryUnderscores(value: string): string {
7986
if (!value.includes('_')) return value
80-
// If the value contains CSS functions, only replace underscores outside them
81-
if (value.includes('(')) {
82-
let result = ''
83-
let depth = 0
84-
for (let i = 0; i < value.length; i++) {
85-
const ch = value[i]
86-
if (ch === '(') depth++
87-
else if (ch === ')') depth--
88-
result += (ch === '_' && depth === 0) ? ' ' : ch
87+
88+
let result = ''
89+
let i = 0
90+
while (i < value.length) {
91+
const ch = value[i]
92+
93+
// Escaped underscore → literal underscore, skip the backslash.
94+
if (ch === '\\' && value[i + 1] === '_') {
95+
result += '_'
96+
i += 2
97+
continue
8998
}
90-
return result
99+
100+
// Detect url(...) and copy its contents verbatim, preserving underscores.
101+
if (ch === 'u' && value.slice(i, i + 4) === 'url(') {
102+
const start = i
103+
let depth = 1
104+
i += 4
105+
while (i < value.length && depth > 0) {
106+
if (value[i] === '(') depth++
107+
else if (value[i] === ')') depth--
108+
i++
109+
}
110+
result += value.slice(start, i)
111+
continue
112+
}
113+
114+
result += ch === '_' ? ' ' : ch
115+
i++
91116
}
92-
return value.replace(/_/g, ' ')
117+
return result
93118
}
94119

95120
/**
@@ -1006,7 +1031,10 @@ function parseClassImpl(className: string): ParsedClass {
10061031
raw: className,
10071032
variants: [],
10081033
utility: arbitraryPropMatch[1],
1009-
value: arbitraryPropMatch[2],
1034+
// Arbitrary properties follow the same underscore-as-space convention
1035+
// as arbitrary values: `[background:center_/_cover]` needs to emit
1036+
// `center / cover`. url() content is preserved by the helper.
1037+
value: convertArbitraryUnderscores(arbitraryPropMatch[2]),
10101038
important,
10111039
arbitrary: true,
10121040
}

packages/crosswind/src/rules-interactivity.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,31 @@ import { resolveColorValue } from './rules'
33

44
// Filters, Tables, Interactivity, SVG, Accessibility utilities
55

6+
// Shared named-size map for `blur-*` and `backdrop-blur-*` (Tailwind parity).
7+
// Callers fall back to `<value>px` when the value isn't a named key.
8+
const BLUR_SIZES: Record<string, string> = {
9+
'none': '0',
10+
'sm': '4px',
11+
'DEFAULT': '8px',
12+
'md': '12px',
13+
'lg': '16px',
14+
'xl': '24px',
15+
'2xl': '40px',
16+
'3xl': '64px',
17+
}
18+
619
// Filter utilities
720
export const filterRule: UtilityRule = (parsed) => {
821
// Handle filter-none
922
if (parsed.raw === 'filter-none') {
1023
return { filter: 'none' }
1124
}
12-
if (parsed.utility === 'blur' && parsed.value) {
13-
const blurMap: Record<string, string> = {
14-
'none': '0',
15-
'sm': '4px',
16-
'DEFAULT': '8px',
17-
'md': '12px',
18-
'lg': '16px',
19-
'xl': '24px',
20-
'2xl': '40px',
21-
'3xl': '64px',
22-
}
23-
return { filter: `blur(${blurMap[parsed.value] || parsed.value})` }
25+
// `blur` (no value) → DEFAULT; `blur-sm` etc. use named sizes;
26+
// `blur-12` → `blur(12px)`; `blur-[10px]` / `blur-[5em]` passes raw.
27+
if (parsed.utility === 'blur') {
28+
const key = parsed.value || 'DEFAULT'
29+
const size = BLUR_SIZES[key] ?? (parsed.value ? (/^\d/.test(parsed.value) ? `${parsed.value}px` : parsed.value) : BLUR_SIZES.DEFAULT)
30+
return { filter: `blur(${size})` }
2431
}
2532
if (parsed.utility === 'brightness' && parsed.value) {
2633
return { filter: `brightness(${Number(parsed.value) / 100})` }
@@ -62,8 +69,12 @@ export const backdropFilterRule: UtilityRule = (parsed): Record<string, string>
6269
if (parsed.raw === 'backdrop-filter-none') {
6370
return { 'backdrop-filter': 'none' }
6471
}
65-
if (parsed.utility === 'backdrop-blur' && parsed.value) {
66-
return { '-webkit-backdrop-filter': `blur(${parsed.value}px)`, 'backdrop-filter': `blur(${parsed.value}px)` }
72+
// `backdrop-blur` (no value) → DEFAULT; `backdrop-blur-sm` etc. use named sizes;
73+
// `backdrop-blur-12` or `backdrop-blur-[10px]` use raw value.
74+
if (parsed.utility === 'backdrop-blur') {
75+
const key = parsed.value || 'DEFAULT'
76+
const size = BLUR_SIZES[key] ?? (parsed.value ? (/^\d/.test(parsed.value) ? `${parsed.value}px` : parsed.value) : BLUR_SIZES.DEFAULT)
77+
return { '-webkit-backdrop-filter': `blur(${size})`, 'backdrop-filter': `blur(${size})` }
6778
}
6879
if (parsed.utility === 'backdrop-brightness' && parsed.value) {
6980
const v = `brightness(${Number(parsed.value) / 100})`

packages/crosswind/src/rules.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,8 +636,16 @@ export const fontSizeRule: UtilityRule = (parsed, config) => {
636636

637637
export const fontWeightRule: UtilityRule = (parsed) => {
638638
if (parsed.utility === 'font' && parsed.value) {
639-
// Handle arbitrary values first
639+
// Handle arbitrary values. Type hints disambiguate the CSS property:
640+
// font-[family-name:Inter_Tight] → font-family
641+
// font-[string:"Press Start"] → font-family (same vibe)
642+
// font-[600] → font-weight (default)
643+
// font-[number:800] → font-weight
640644
if (parsed.arbitrary) {
645+
const hint = parsed.typeHint
646+
if (hint === 'family-name' || hint === 'string') {
647+
return { 'font-family': parsed.value }
648+
}
641649
return { 'font-weight': parsed.value }
642650
}
643651
const weights: Record<string, string> = {

packages/crosswind/test/arbitrary.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,5 +559,143 @@ describe('Arbitrary Values and Properties', () => {
559559
expect(css).toContain('.\\[content\\:a\\>b\\]')
560560
})
561561
})
562+
563+
// =========================================================================
564+
// Regression: Tailwind-style underscore → space conversion in arbitrary
565+
// values. CSS class names can't contain spaces, so Tailwind's convention
566+
// is to write `_` and have the engine convert it to ` ` when emitting the
567+
// property value. The ONLY exception is `url(...)` — URLs legitimately
568+
// contain underscores in paths, so those must be preserved verbatim.
569+
//
570+
// These tests were added after stx/drivly hit a `grid-cols-[300px_1fr]`
571+
// bug where the value stayed as `300px_1fr` in the output (invalid CSS),
572+
// and `max-h-[calc(100vh_-_120px)]` collapsed to `calc(100vh_-_120px)`
573+
// (invalid operator) because the old parser preserved underscores inside
574+
// ANY parentheses. See `convertArbitraryUnderscores` in parser.ts.
575+
// =========================================================================
576+
describe('Underscore-to-space conversion (regression)', () => {
577+
it('converts underscores to spaces in multi-track grid columns', () => {
578+
const gen = new CSSGenerator(defaultConfig)
579+
gen.generate('grid-cols-[300px_1fr]')
580+
const css = gen.toCSS(false)
581+
expect(css).toContain('grid-template-columns: 300px 1fr;')
582+
expect(css).not.toContain('grid-template-columns: 300px_1fr')
583+
})
584+
585+
it('converts underscores to spaces with lg: variant', () => {
586+
const gen = new CSSGenerator(defaultConfig)
587+
gen.generate('lg:grid-cols-[300px_1fr]')
588+
const css = gen.toCSS(false)
589+
expect(css).toContain('grid-template-columns: 300px 1fr;')
590+
expect(css).not.toContain('grid-template-columns: 300px_1fr')
591+
})
592+
593+
it('converts underscores to spaces inside calc()', () => {
594+
// Spaces around the `-` operator are required for valid CSS calc().
595+
// Users write `_-_` in the class name; the output must have real spaces.
596+
const gen = new CSSGenerator(defaultConfig)
597+
gen.generate('max-h-[calc(100vh_-_120px)]')
598+
const css = gen.toCSS(false)
599+
expect(css).toContain('max-height: calc(100vh - 120px);')
600+
})
601+
602+
it('converts underscores to spaces inside clamp()', () => {
603+
const gen = new CSSGenerator(defaultConfig)
604+
gen.generate('w-[clamp(200px,_50%,_800px)]')
605+
const css = gen.toCSS(false)
606+
expect(css).toContain('width: clamp(200px, 50%, 800px);')
607+
})
608+
609+
it('converts underscores to spaces inside min()/max()', () => {
610+
const gen = new CSSGenerator(defaultConfig)
611+
gen.generate('w-[min(100%,_500px)]')
612+
gen.generate('h-[max(50vh,_300px)]')
613+
const css = gen.toCSS(false)
614+
expect(css).toContain('width: min(100%, 500px);')
615+
expect(css).toContain('height: max(50vh, 300px);')
616+
})
617+
618+
it('converts underscores to spaces in multi-value shadow (via arbitrary property)', () => {
619+
// `0_4px_16px_rgba(...)` → `0 4px 16px rgba(...)` — nested parens
620+
// around the color must not prevent the top-level underscores from
621+
// becoming spaces. Tested via arbitrary property form since shadow-
622+
// utility may not emit for complex rgba-containing values.
623+
const gen = new CSSGenerator(defaultConfig)
624+
gen.generate('[box-shadow:0_4px_16px_rgba(0,0,0,0.06)]')
625+
const css = gen.toCSS(false)
626+
expect(css).toContain('box-shadow: 0 4px 16px rgba(0,0,0,0.06);')
627+
})
628+
629+
it('converts underscores to spaces in linear-gradient argument list', () => {
630+
// Tested via arbitrary property form since bg- doesn't emit
631+
// background-image for gradient values.
632+
const gen = new CSSGenerator(defaultConfig)
633+
gen.generate('[background-image:linear-gradient(135deg,_#fff,_#000)]')
634+
const css = gen.toCSS(false)
635+
expect(css).toContain('background-image: linear-gradient(135deg, #fff, #000);')
636+
})
637+
638+
it('converts underscores inside nested CSS functions (repeat(auto-fit, minmax(...)))', () => {
639+
const gen = new CSSGenerator(defaultConfig)
640+
gen.generate('grid-cols-[repeat(auto-fit,_minmax(200px,_1fr))]')
641+
const css = gen.toCSS(false)
642+
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));')
643+
})
644+
645+
it('PRESERVES underscores inside url() (URLs are path-bearing)', () => {
646+
// The whole point of the url() exception — it's the one CSS function
647+
// where underscores are meaningful rather than space stand-ins.
648+
const gen = new CSSGenerator(defaultConfig)
649+
gen.generate('[background-image:url(https://example.com/foo_bar_baz.png)]')
650+
const css = gen.toCSS(false)
651+
expect(css).toContain('url(https://example.com/foo_bar_baz.png)')
652+
expect(css).not.toContain('foo bar baz.png')
653+
})
654+
655+
it('PRESERVES underscores in data-URL paths', () => {
656+
const gen = new CSSGenerator(defaultConfig)
657+
gen.generate('[background:url(data:image/svg+xml;base64,my_encoded_data)]')
658+
const css = gen.toCSS(false)
659+
expect(css).toContain('url(data:image/svg+xml;base64,my_encoded_data)')
660+
})
661+
662+
it('converts underscores outside url() while preserving them inside', () => {
663+
// Mixed case — top-level underscores between shorthand parts become
664+
// spaces, the underscores INSIDE the url() stay put.
665+
const gen = new CSSGenerator(defaultConfig)
666+
gen.generate('[background:center_/_cover_url(/assets/cover_image.png)]')
667+
const css = gen.toCSS(false)
668+
expect(css).toContain('background: center / cover url(/assets/cover_image.png);')
669+
})
670+
671+
it('treats escaped underscores (\\_) as literal underscores', () => {
672+
// Escape hatch: if you truly need a literal `_` in a non-url context,
673+
// write `\_`. Used rarely but must work.
674+
const gen = new CSSGenerator(defaultConfig)
675+
gen.generate('content-[my\\_literal\\_text]')
676+
const css = gen.toCSS(false)
677+
expect(css).toContain('my_literal_text')
678+
})
679+
680+
it('works with font-family type hint and underscores', () => {
681+
// `font-[family-name:Inter_Tight]` → `font-family: Inter Tight`
682+
const gen = new CSSGenerator(defaultConfig)
683+
gen.generate('font-[family-name:Inter_Tight]')
684+
const css = gen.toCSS(false)
685+
expect(css).toContain('font-family: Inter Tight;')
686+
})
687+
688+
it('parseClass exposes the space-converted value', () => {
689+
// End-to-end: the parsed form should already carry the converted value.
690+
const parsed = parseClass('grid-cols-[120px_1fr_200px]')
691+
expect(parsed.value).toBe('120px 1fr 200px')
692+
693+
const parsedCalc = parseClass('max-h-[calc(100vh_-_120px)]')
694+
expect(parsedCalc.value).toBe('calc(100vh - 120px)')
695+
696+
const parsedUrl = parseClass('bg-[url(/foo_bar.png)]')
697+
expect(parsedUrl.value).toBe('url(/foo_bar.png)')
698+
})
699+
})
562700
})
563701
})

packages/crosswind/test/grid.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,50 @@ describe('Grid Utilities', () => {
3535
gen.generate('grid-cols-none')
3636
expect(gen.toCSS(false)).toContain('grid-template-columns: none;')
3737
})
38+
39+
// Regression: `grid-cols-[300px_1fr]` must emit a space-separated value.
40+
// Tailwind's arbitrary-value underscore convention requires `_` → ` `,
41+
// because CSS class names can't contain literal spaces. Previously
42+
// crosswind preserved underscores inside any parentheses, which was fine
43+
// for `grid-cols-[300px_1fr]` (no parens) but still mis-emitted because
44+
// the grid-cols rule passed the value through verbatim.
45+
it('should convert underscores to spaces in arbitrary multi-track columns', () => {
46+
const gen = new CSSGenerator(defaultConfig)
47+
gen.generate('grid-cols-[300px_1fr]')
48+
const css = gen.toCSS(false)
49+
expect(css).toContain('grid-template-columns: 300px 1fr;')
50+
expect(css).not.toMatch(/grid-template-columns:\s*300px_1fr/)
51+
})
52+
53+
it('should convert underscores in arbitrary columns with three tracks', () => {
54+
const gen = new CSSGenerator(defaultConfig)
55+
gen.generate('grid-cols-[200px_1fr_auto]')
56+
expect(gen.toCSS(false)).toContain('grid-template-columns: 200px 1fr auto;')
57+
})
58+
59+
it('should convert underscores in arbitrary columns under lg: variant', () => {
60+
const gen = new CSSGenerator(defaultConfig)
61+
gen.generate('lg:grid-cols-[300px_1fr]')
62+
const css = gen.toCSS(false)
63+
expect(css).toContain('grid-template-columns: 300px 1fr;')
64+
// The selector still contains the raw `_1fr]` in the escaped class name
65+
expect(css).toContain('.lg\\:grid-cols-\\[300px_1fr\\]')
66+
})
67+
68+
it('should convert underscores inside nested CSS functions (repeat/minmax)', () => {
69+
// This exercises the inside-parens code path. Previously `_` inside
70+
// parens stayed an underscore, producing `minmax(200px,_1fr)` — invalid.
71+
const gen = new CSSGenerator(defaultConfig)
72+
gen.generate('grid-cols-[repeat(auto-fit,_minmax(200px,_1fr))]')
73+
expect(gen.toCSS(false)).toContain('grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));')
74+
})
75+
76+
it('should convert underscores in arbitrary rows the same way', () => {
77+
// Mirror test for grid-rows so rule coverage stays symmetric.
78+
const gen = new CSSGenerator(defaultConfig)
79+
gen.generate('grid-rows-[50px_1fr_40px]')
80+
expect(gen.toCSS(false)).toContain('grid-template-rows: 50px 1fr 40px;')
81+
})
3882
})
3983

4084
describe('Grid Template Rows', () => {

0 commit comments

Comments
 (0)