@@ -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} )
0 commit comments