diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d35cb1e67f..92bf54c672c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure third-party plugins with `exports` in their `package.json` are resolved correctly ([#14775](https://github.com/tailwindlabs/tailwindcss/pull/14775)) - Ensure underscores in the `url()` function are never unescaped ([#14776](https://github.com/tailwindlabs/tailwindcss/pull/14776)) - _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)) +- _Upgrade (experimental)_: Ensure that CSS variable access when migrating arbitrary candidates that reference theme values with dots in the key path do not require escaping (e.g. `m-[var(--spacing-1_5)]`) ([#14778](https://github.com/tailwindlabs/tailwindcss/pull/14778)) ## [4.0.0-alpha.29] - 2024-10-23 diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 83d1bc89e860..56767178606a 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -1,5 +1,5 @@ import { expect } from 'vitest' -import { css, html, js, json, test } from '../utils' +import { candidate, css, html, js, json, test } from '../utils' test( `upgrades a v3 project to v4`, @@ -9,6 +9,9 @@ test( { "dependencies": { "@tailwindcss/upgrade": "workspace:^" + }, + "devDependencies": { + "@tailwindcss/cli": "workspace:^" } } `, @@ -20,7 +23,9 @@ test( `, 'src/index.html': html`

🤠👋

-
+
`, 'src/input.css': css` @tailwind base; @@ -42,7 +47,9 @@ test( " --- ./src/index.html ---

🤠👋

-
+
--- ./src/input.css --- @import 'tailwindcss'; @@ -92,6 +99,18 @@ test( expect(packageJson.dependencies).toMatchObject({ tailwindcss: expect.stringContaining('4.0.0'), }) + + // Ensure the v4 project compiles correctly + await exec('npx tailwindcss --input src/input.css --output dist/out.css') + + await fs.expectFileToContain('dist/out.css', [ + candidate`flex!`, + candidate`sm:block!`, + candidate`bg-linear-to-t`, + candidate`bg-[var(--my-red)]`, + candidate`max-w-[var(--breakpoint-md)]`, + candidate`ml-[var(--spacing-1\_5)`, + ]) }, ) diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts index 296a5cc5e8d5..4c310fe6f815 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.test.ts @@ -118,6 +118,13 @@ const candidates = [ // Keep spaces in strings ['content-["hello_world"]', 'content-["hello_world"]'], ['content-[____"hello_world"___]', 'content-["hello_world"]'], + + // Do not escape underscores for url() and CSS variable in var() + ['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'], + [ + 'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]', + 'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]', + ], ] const variants = [ diff --git a/packages/@tailwindcss-upgrade/src/template/candidates.ts b/packages/@tailwindcss-upgrade/src/template/candidates.ts index b97ff8ed04ab..e71fa462ed8c 100644 --- a/packages/@tailwindcss-upgrade/src/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/template/candidates.ts @@ -187,9 +187,9 @@ function printArbitraryValue(input: string) { }) } + recursivelyEscapeUnderscores(ast) + return ValueParser.toCss(ast) - .replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is - .replaceAll(' ', '_') // Replace spaces with underscores } function simplifyArbitraryVariant(input: string) { @@ -213,3 +213,51 @@ function simplifyArbitraryVariant(input: string) { return input } + +function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) { + for (let node of ast) { + switch (node.kind) { + case 'function': { + if (node.value === 'url' || node.value.endsWith('_url')) { + // Don't decode underscores in url() but do decode the function name + node.value = escapeUnderscore(node.value) + break + } + + if (node.value === 'var' || node.value.endsWith('_var')) { + // Don't decode underscores in the first argument of var() but do + // decode the function name + node.value = escapeUnderscore(node.value) + for (let i = 0; i < node.nodes.length; i++) { + if (i == 0 && node.nodes[i].kind === 'word') { + continue + } + recursivelyEscapeUnderscores([node.nodes[i]]) + } + break + } + + node.value = escapeUnderscore(node.value) + recursivelyEscapeUnderscores(node.nodes) + break + } + case 'separator': + case 'word': { + node.value = escapeUnderscore(node.value) + break + } + default: + never() + } + } +} + +function never(): never { + throw new Error('This should never happen') +} + +function escapeUnderscore(value: string): string { + return value + .replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is + .replaceAll(' ', '_') // Replace spaces with underscores +}