diff --git a/CHANGELOG.md b/CHANGELOG.md index ef34a1a71862..4364d8059c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831)) - Fix HAML extraction with embedded Ruby ([#17846](https://github.com/tailwindlabs/tailwindcss/pull/17846)) - Don't scan files for utilities when using `@reference` ([#17836](https://github.com/tailwindlabs/tailwindcss/pull/17836)) +- Fix incorrectly replacing `_` with ` ` in arbitrary modifier shorthand `bg-red-500/(--my_opacity)` ([#17889](https://github.com/tailwindlabs/tailwindcss/pull/17889)) ## [4.1.5] - 2025-04-30 diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index 3c54bef7a20d..4355071f2aac 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -1087,6 +1087,7 @@ it('should parse a utility with an implicit variable as the modifier using the s let utilities = new Utilities() utilities.functional('bg', () => []) + // Standard case (no underscores) expect(run('bg-red-500/(--value)', { utilities })).toMatchInlineSnapshot(` [ { @@ -1107,6 +1108,156 @@ it('should parse a utility with an implicit variable as the modifier using the s }, ] `) + + // Should preserve underscores + expect(run('bg-red-500/(--with_underscore)', { utilities })).toMatchInlineSnapshot(` + [ + { + "important": false, + "kind": "functional", + "modifier": { + "kind": "arbitrary", + "value": "var(--with_underscore)", + }, + "raw": "bg-red-500/(--with_underscore)", + "root": "bg", + "value": { + "fraction": null, + "kind": "named", + "value": "red-500", + }, + "variants": [], + }, + ] + `) + + // Should remove underscores in fallback values + expect(run('bg-red-500/(--with_underscore,fallback_value)', { utilities })) + .toMatchInlineSnapshot(` + [ + { + "important": false, + "kind": "functional", + "modifier": { + "kind": "arbitrary", + "value": "var(--with_underscore,fallback value)", + }, + "raw": "bg-red-500/(--with_underscore,fallback_value)", + "root": "bg", + "value": { + "fraction": null, + "kind": "named", + "value": "red-500", + }, + "variants": [], + }, + ] + `) + + // Should keep underscores in the CSS variable itself, but remove underscores + // in fallback values + expect(run('bg-(--a_b,c_d_var(--e_f,g_h))/(--i_j,k_l_var(--m_n,o_p))', { utilities })) + .toMatchInlineSnapshot(` + [ + { + "important": false, + "kind": "functional", + "modifier": { + "kind": "arbitrary", + "value": "var(--i_j,k l var(--m_n,o p))", + }, + "raw": "bg-(--a_b,c_d_var(--e_f,g_h))/(--i_j,k_l_var(--m_n,o_p))", + "root": "bg", + "value": { + "dataType": null, + "kind": "arbitrary", + "value": "var(--a_b,c d var(--e_f,g h))", + }, + "variants": [], + }, + ] + `) +}) + +it('should not parse an invalid arbitrary shorthand modifier', () => { + let utilities = new Utilities() + utilities.functional('bg', () => []) + + // Completely empty + expect(run('bg-red-500/()', { utilities })).toMatchInlineSnapshot(`[]`) + + // Invalid due to leading spaces + expect(run('bg-red-500/(_--)', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-red-500/(_--x)', { utilities })).toMatchInlineSnapshot(`[]`) + + // Invalid due to leading spaces + expect(run('bg-red-500/(_--)', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-red-500/(_--x)', { utilities })).toMatchInlineSnapshot(`[]`) + + // Invalid due to top-level `;` or `}` characters + expect(run('bg-red-500/(--x;--y)', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-red-500/(--x:{foo:bar})', { utilities })).toMatchInlineSnapshot(`[]`) + + // Valid, but ensuring that we didn't make an off-by-one error + expect(run('bg-red-500/(--x)', { utilities })).toMatchInlineSnapshot(` + [ + { + "important": false, + "kind": "functional", + "modifier": { + "kind": "arbitrary", + "value": "var(--x)", + }, + "raw": "bg-red-500/(--x)", + "root": "bg", + "value": { + "fraction": null, + "kind": "named", + "value": "red-500", + }, + "variants": [], + }, + ] + `) +}) + +it('should not parse an invalid arbitrary shorthand value', () => { + let utilities = new Utilities() + utilities.functional('bg', () => []) + + // Completely empty + expect(run('bg-()', { utilities })).toMatchInlineSnapshot(`[]`) + + // Invalid due to leading spaces + expect(run('bg-(_--)', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-(_--x)', { utilities })).toMatchInlineSnapshot(`[]`) + + // Invalid due to leading spaces + expect(run('bg-(_--)', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-(_--x)', { utilities })).toMatchInlineSnapshot(`[]`) + + // Invalid due to top-level `;` or `}` characters + expect(run('bg-(--x;--y)', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-(--x:{foo:bar})', { utilities })).toMatchInlineSnapshot(`[]`) + + // Valid, but ensuring that we didn't make an off-by-one error + expect(run('bg-(--x)', { utilities })).toMatchInlineSnapshot(` + [ + { + "important": false, + "kind": "functional", + "modifier": null, + "raw": "bg-(--x)", + "root": "bg", + "value": { + "dataType": null, + "kind": "arbitrary", + "value": "var(--x)", + }, + "variants": [], + }, + ] + `) }) it('should not parse a utility with an implicit invalid variable as the modifier using the shorthand', () => { diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index aff6d74dc116..5a5704989a30 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -411,7 +411,10 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter // An arbitrary value with `(…)` should always start with `--` since it // represents a CSS variable. - if (value[0] !== '-' && value[1] !== '-') return + if (value[0] !== '-' || value[1] !== '-') return + + // Values can't contain `;` or `}` characters at the top-level. + if (!isValidArbitrary(value)) return roots = [[root, dataType === null ? `[var(${value})]` : `[${dataType}:var(${value})]`]] } @@ -523,21 +526,24 @@ function parseModifier(modifier: string): CandidateModifier | null { } if (modifier[0] === '(' && modifier[modifier.length - 1] === ')') { - let arbitraryValue = decodeArbitraryValue(modifier.slice(1, -1)) + // Drop the `(` and `)` characters + modifier = modifier.slice(1, -1) + + // A modifier with `(…)` should always start with `--` since it + // represents a CSS variable. + if (modifier[0] !== '-' || modifier[1] !== '-') return null // Values can't contain `;` or `}` characters at the top-level. - if (!isValidArbitrary(arbitraryValue)) return null + if (!isValidArbitrary(modifier)) return null - // Empty arbitrary values are invalid. E.g.: `data-():` - // ^^ - if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null + // Wrap the value in `var(…)` to ensure that it is a valid CSS variable. + modifier = `var(${modifier})` - // Arbitrary values must start with `--` since it represents a CSS variable. - if (arbitraryValue[0] !== '-' && arbitraryValue[1] !== '-') return null + let arbitraryValue = decodeArbitraryValue(modifier) return { kind: 'arbitrary', - value: `var(${arbitraryValue})`, + value: arbitraryValue, } } @@ -679,7 +685,7 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia if (arbitraryValue.length === 0 || arbitraryValue.trim().length === 0) return null // Arbitrary values must start with `--` since it represents a CSS variable. - if (arbitraryValue[0] !== '-' && arbitraryValue[1] !== '-') return null + if (arbitraryValue[0] !== '-' || arbitraryValue[1] !== '-') return null return { kind: 'functional', @@ -1030,7 +1036,7 @@ function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) { case 'word': { // Dashed idents and variables `var(--my-var)` and `--my-var` should not // have underscores escaped - if (node.value[0] !== '-' && node.value[1] !== '-') { + if (node.value[0] !== '-' || node.value[1] !== '-') { node.value = escapeUnderscore(node.value) } break