Skip to content

Commit b722ebc

Browse files
Upgrade: Ensure underscores in url() and var() are not escaped (#14778)
This PR fixes an issue where currently a `theme()` function call inside an arbitrary value that used a dot in the key path: ```jsx let className = "ml-[theme(spacing[1.5])]" ``` Was causing issues when going though the codemod. The issue is that for candidates, we require `_` to be _escaped_, since otherwise they will be replaced with underscore. When going through the codemods, the above candidate would be translated to the following CSS variable access: ```js let className = "ml-[var(--spacing-1\_5))" ``` Because the underscore was escaped, we now have an invalid string inside a JavaScript file (as the `\` would escape inside the quoted string. To resolve this, we decided that this common case (as its used by the Tailwind CSS default theme) should work without escaping. In #14776, we made the changes that CSS variables used via `var()` no longer unescape underscores. This PR extends that so that the Variant printer (that creates the serialized candidate representation after the codemods make changes) take this new encoding into account. This will result in the above example being translated into: ```js let className = "ml-[var(--spacing-1_5))" ``` With no more escaping. Nice! ## Test Plan I have added test for this to the kitchen-sink upgrade tests. Furthermore, to ensure this really works full-stack, I have updated the kitchen-sink test to _actually build the migrated project with Tailwind CSS v4_. After doing so, we can assert that we indeed have the right class name in the generated CSS. --------- Co-authored-by: Adam Wathan <[email protected]>
1 parent dc9e034 commit b722ebc

File tree

4 files changed

+85
-5
lines changed

4 files changed

+85
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Ensure third-party plugins with `exports` in their `package.json` are resolved correctly ([#14775](https://github.com/tailwindlabs/tailwindcss/pull/14775))
1919
- Ensure underscores in the `url()` function are never unescaped ([#14776](https://github.com/tailwindlabs/tailwindcss/pull/14776))
2020
- _Upgrade (experimental)_: Ensure `@import` statements for relative CSS files are actually migrated to use relative path syntax ([#14769](https://github.com/tailwindlabs/tailwindcss/pull/14769))
21+
- _Upgrade (experimental)_: Don't escape underscores when printing theme values migrated to CSS variables in arbitrary values (e.g. `m-[var(--spacing-1_5)]` instead of `m-[var(--spacing-1\_5)]`) ([#14778](https://github.com/tailwindlabs/tailwindcss/pull/14778))
2122

2223
## [4.0.0-alpha.29] - 2024-10-23
2324

integrations/upgrade/index.test.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from 'vitest'
2-
import { css, html, js, json, test } from '../utils'
2+
import { candidate, css, html, js, json, test } from '../utils'
33

44
test(
55
`upgrades a v3 project to v4`,
@@ -9,6 +9,9 @@ test(
99
{
1010
"dependencies": {
1111
"@tailwindcss/upgrade": "workspace:^"
12+
},
13+
"devDependencies": {
14+
"@tailwindcss/cli": "workspace:^"
1215
}
1316
}
1417
`,
@@ -20,7 +23,9 @@ test(
2023
`,
2124
'src/index.html': html`
2225
<h1>🤠👋</h1>
23-
<div class="!flex sm:!block bg-gradient-to-t bg-[--my-red] max-w-screen-md"></div>
26+
<div
27+
class="!flex sm:!block bg-gradient-to-t bg-[--my-red] max-w-screen-md ml-[theme(spacing[1.5])]"
28+
></div>
2429
`,
2530
'src/input.css': css`
2631
@tailwind base;
@@ -42,7 +47,9 @@ test(
4247
"
4348
--- ./src/index.html ---
4449
<h1>🤠👋</h1>
45-
<div class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)] max-w-[var(--breakpoint-md)]"></div>
50+
<div
51+
class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)] max-w-[var(--breakpoint-md)] ml-[var(--spacing-1_5)]"
52+
></div>
4653
4754
--- ./src/input.css ---
4855
@import 'tailwindcss';
@@ -92,6 +99,18 @@ test(
9299
expect(packageJson.dependencies).toMatchObject({
93100
tailwindcss: expect.stringContaining('4.0.0'),
94101
})
102+
103+
// Ensure the v4 project compiles correctly
104+
await exec('npx tailwindcss --input src/input.css --output dist/out.css')
105+
106+
await fs.expectFileToContain('dist/out.css', [
107+
candidate`flex!`,
108+
candidate`sm:block!`,
109+
candidate`bg-linear-to-t`,
110+
candidate`bg-[var(--my-red)]`,
111+
candidate`max-w-[var(--breakpoint-md)]`,
112+
candidate`ml-[var(--spacing-1\_5)`,
113+
])
95114
},
96115
)
97116

packages/@tailwindcss-upgrade/src/template/candidates.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ const candidates = [
118118
// Keep spaces in strings
119119
['content-["hello_world"]', 'content-["hello_world"]'],
120120
['content-[____"hello_world"___]', 'content-["hello_world"]'],
121+
122+
// Do not escape underscores for url() and CSS variable in var()
123+
['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'],
124+
[
125+
'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]',
126+
'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]',
127+
],
121128
]
122129

123130
const variants = [

packages/@tailwindcss-upgrade/src/template/candidates.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,9 @@ function printArbitraryValue(input: string) {
187187
})
188188
}
189189

190+
recursivelyEscapeUnderscores(ast)
191+
190192
return ValueParser.toCss(ast)
191-
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
192-
.replaceAll(' ', '_') // Replace spaces with underscores
193193
}
194194

195195
function simplifyArbitraryVariant(input: string) {
@@ -213,3 +213,56 @@ function simplifyArbitraryVariant(input: string) {
213213

214214
return input
215215
}
216+
217+
function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
218+
for (let node of ast) {
219+
switch (node.kind) {
220+
case 'function': {
221+
if (node.value === 'url' || node.value.endsWith('_url')) {
222+
// Don't decode underscores in url() but do decode the function name
223+
node.value = escapeUnderscore(node.value)
224+
break
225+
}
226+
227+
if (
228+
node.value === 'var' ||
229+
node.value.endsWith('_var') ||
230+
node.value === 'theme' ||
231+
node.value.endsWith('_theme')
232+
) {
233+
// Don't decode underscores in the first argument of var() and theme()
234+
// but do decode the function name
235+
node.value = escapeUnderscore(node.value)
236+
for (let i = 0; i < node.nodes.length; i++) {
237+
if (i == 0 && node.nodes[i].kind === 'word') {
238+
continue
239+
}
240+
recursivelyEscapeUnderscores([node.nodes[i]])
241+
}
242+
break
243+
}
244+
245+
node.value = escapeUnderscore(node.value)
246+
recursivelyEscapeUnderscores(node.nodes)
247+
break
248+
}
249+
case 'separator':
250+
case 'word': {
251+
node.value = escapeUnderscore(node.value)
252+
break
253+
}
254+
default:
255+
never(node)
256+
}
257+
}
258+
}
259+
260+
function never(value: never): never {
261+
throw new Error(`Unexpected value: ${value}`)
262+
}
263+
264+
function escapeUnderscore(value: string): string {
265+
return value
266+
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
267+
.replaceAll(' ', '_') // Replace spaces with underscores
268+
}

0 commit comments

Comments
 (0)