Merge branch 'next' into 10-21-escape_js_theme_configuration_keys

This commit is contained in:
Robin Malfait 2024-10-22 18:31:59 +02:00 • committed by GitHub
commit 56608a5ddf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 328 additions and 94 deletions

View file

@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Migrate `plugins` with options to CSS ([#14700](https://github.com/tailwindlabs/tailwindcss/pull/14700))
- _Upgrade (experimental)_: Allow JS configuration files with `corePlugins` options to be migrated to CSS ([#14742](https://github.com/tailwindlabs/tailwindcss/pull/14742))
- _Upgrade (experimental)_: Migrate `@variants` and `@responsive` directives ([#14748](https://github.com/tailwindlabs/tailwindcss/pull/14748))
- _Upgrade (experimental)_: Migrate `@screen` directive ([#14749](https://github.com/tailwindlabs/tailwindcss/pull/14749))
### Fixed
@ -20,11 +22,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `postcss` as a dependency of `@tailwindcss/postcss` ([#14750](https://github.com/tailwindlabs/tailwindcss/pull/14750))
- Ensure the JS `theme()` function can reference CSS theme variables that contain special characters without escaping them (e.g. referencing `--width-1\/2` as `theme('width.1/2')`) ([#14739](https://github.com/tailwindlabs/tailwindcss/pull/14739))
- Ensure JS theme keys containing special characters correctly produce utility classes (e.g. `'1/2': 50%` to `w-1/2`) ([#14739](https://github.com/tailwindlabs/tailwindcss/pull/14739))
- Ensure loading stylesheets via the `?raw` and `?url` static asset query works when using the Vite plugin ([#14716](https://github.com/tailwindlabs/tailwindcss/pull/14716))
- _Upgrade (experimental)_: Migrate `flex-grow` to `grow` and `flex-shrink` to `shrink` ([#14721](https://github.com/tailwindlabs/tailwindcss/pull/14721))
- _Upgrade (experimental)_: Minify arbitrary values when printing candidates ([#14720](https://github.com/tailwindlabs/tailwindcss/pull/14720))
- _Upgrade (experimental)_: Ensure legacy theme values ending in `1` (like `theme(spacing.1)`) are correctly migrated to custom properties ([#14724](https://github.com/tailwindlabs/tailwindcss/pull/14724))
- _Upgrade (experimental)_: Migrate arbitrary values to bare values for the `from-*`, `via-*`, and `to-*` utilities ([#14725](https://github.com/tailwindlabs/tailwindcss/pull/14725))
- _Upgrade (experimental)_: Ensure `layer(utilities)` is removed from `@import` to keep `@utility` top-level ([#14738](https://github.com/tailwindlabs/tailwindcss/pull/14738))
- _Upgrade (experimental)_: Don't migrate important modifiers that are actually logical negations (e.g. `let foo = !border` to `let foo = border!`) ([#14737](https://github.com/tailwindlabs/tailwindcss/pull/14737))
### Changed

View file

@ -26,6 +26,12 @@ test(
@tailwind base;
@tailwind components;
@tailwind utilities;
@variants hover, focus {
.foo {
color: red;
}
}
`,
},
},
@ -40,6 +46,10 @@ test(
--- ./src/input.css ---
@import 'tailwindcss';
@utility foo {
color: red;
}
"
`)

View file

@ -72,6 +72,11 @@ test(
@tailwind components;
@tailwind utilities;
`,
'src/test.js': ts`
export default {
shouldNotUse: !border.shouldUse,
}
`,
'node_modules/my-external-lib/src/template.html': html`
<div class="text-red-500">
Hello world!
@ -82,7 +87,7 @@ test(
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
expect(await fs.dumpFiles('src/**/*.{css,js}')).toMatchInlineSnapshot(`
"
--- src/input.css ---
@import 'tailwindcss';
@ -134,6 +139,11 @@ test(
}
}
}
--- src/test.js ---
export default {
shouldNotUse: !border.shouldUse,
}
"
`)

View file

@ -504,3 +504,64 @@ test(
})
},
)
test(
`does not interfere with ?raw and ?url static asset handling`,
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^5.3.5"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<head>
<script type="module" src="./src/index.js"></script>
</head>
`,
'src/index.js': js`
import url from './index.css?url'
import raw from './index.css?raw'
`,
'src/index.css': css`@import 'tailwindcss';`,
},
},
async ({ spawn, getFreePort }) => {
let port = await getFreePort()
await spawn(`pnpm vite dev --port ${port}`)
await retryAssertion(async () => {
// We have to load the .js file first so that the static assets are
// resolved
await fetch(`http://localhost:${port}/src/index.js`).then((r) => r.text())
let [raw, url] = await Promise.all([
fetch(`http://localhost:${port}/src/index.css?raw`).then((r) => r.text()),
fetch(`http://localhost:${port}/src/index.css?url`).then((r) => r.text()),
])
expect(firstLine(raw)).toBe(`export default "@import 'tailwindcss';"`)
expect(firstLine(url)).toBe(`export default "/src/index.css"`)
})
},
)
function firstLine(str: string) {
return str.split('\n')[0]
}

View file

@ -514,7 +514,7 @@ exports[`\`@import 'tailwindcss'\` is replaced with the generated CSS 1`] = `
}
input:where(:not([type="button"], [type="reset"], [type="submit"])), select, textarea {
border: 1px solid;
border-width: 1px;
}
button, input:where([type="button"], [type="reset"], [type="submit"]) {

View file

@ -41,6 +41,24 @@ it('should migrate a built-in breakpoint', async () => {
`)
})
it('should migrate `@screen` with a built-in breakpoint', async () => {
expect(
await migrate(css`
@screen md {
.foo {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
"@media (width >= theme(--breakpoint-md)) {
.foo {
color: red;
}
}"
`)
})
it('should migrate a custom min-width screen (string)', async () => {
expect(
await migrate(

View file

@ -24,6 +24,13 @@ export function migrateMediaScreen({
return value ? buildMediaQuery(value) : null
})
// First migrate `@screen md` to `@media screen(md)`
root.walkAtRules('screen', (node) => {
node.name = 'media'
node.params = `screen(${node.params})`
})
// Then migrate the `screen(…)` function
root.walkAtRules((rule) => {
if (rule.name !== 'media') return

View file

@ -400,3 +400,19 @@ it('should drop `@tailwind variants;`', async () => {
`),
).toEqual('')
})
it('should replace `@responsive` with its children', async () => {
expect(
await migrate(css`
@responsive {
.foo {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
".foo {
color: red;
}"
`)
})

View file

@ -54,6 +54,18 @@ export function migrateTailwindDirectives(options: { newPrefix: string | null })
) {
node.remove()
}
// Replace Tailwind CSS v2 directives that still worked in v3.
else if (node.name === 'responsive') {
if (node.nodes) {
for (let child of node.nodes) {
child.raws.tailwind_pretty = true
}
node.replaceWith(node.nodes)
} else {
node.remove()
}
}
})
// Insert default import if all directives are present

View file

@ -0,0 +1,33 @@
import dedent from 'dedent'
import postcss from 'postcss'
import { expect, it } from 'vitest'
import { formatNodes } from './format-nodes'
import { migrateVariantsDirective } from './migrate-variants-directive'
const css = dedent
function migrate(input: string) {
return postcss()
.use(migrateVariantsDirective())
.use(formatNodes())
.process(input, { from: expect.getState().testPath })
.then((result) => result.css)
}
it('should replace `@variants` with `@layer utilities`', async () => {
expect(
await migrate(css`
@variants hover, focus {
.foo {
color: red;
}
}
`),
).toMatchInlineSnapshot(`
"@layer utilities {
.foo {
color: red;
}
}"
`)
})

View file

@ -0,0 +1,35 @@
import { type Plugin, type Root } from 'postcss'
export function migrateVariantsDirective(): Plugin {
function migrate(root: Root) {
root.walkAtRules('variants', (node) => {
// Migrate `@variants` to `@utility` because `@variants` make the classes
// an actual utility.
// ```css
// @variants hover {
// .foo {}
// }
// ```
//
// Means that you can do this in your HTML:
// ```html
// <div class="focus:foo"></div>
// ```
//
// Notice the `focus:`, even though we _only_ configured the `hover`
// variant.
//
// This means that we can convert it to an `@layer utilities` rule. Later,
// this will get converted to an `@utility` rule.
if (node.name === 'variants') {
node.name = 'layer'
node.params = 'utilities'
}
})
}
return {
postcssPlugin: '@tailwindcss/upgrade/migrate-variants-directive',
OnceExit: migrate,
}
}

View file

@ -11,6 +11,7 @@ import { migrateMediaScreen } from './codemods/migrate-media-screen'
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
import { migrateThemeToVar } from './codemods/migrate-theme-to-var'
import { migrateVariantsDirective } from './codemods/migrate-variants-directive'
import type { JSConfigMigration } from './migrate-js-config'
import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet'
import { resolveCssId } from './utils/resolve'
@ -38,6 +39,7 @@ export async function migrateContents(
.use(migrateAtApply(options))
.use(migrateThemeToVar(options))
.use(migrateMediaScreen(options))
.use(migrateVariantsDirective())
.use(migrateAtLayerUtilities(stylesheet))
.use(migrateMissingLayers())
.use(migrateTailwindDirectives(options))

View file

@ -15,5 +15,39 @@ test.each([
base: __dirname,
})
expect(important(designSystem, {}, candidate)).toEqual(result)
expect(
important(designSystem, {}, candidate, {
contents: `"${candidate}"`,
start: 1,
end: candidate.length + 1,
}),
).toEqual(result)
})
test('does not match false positives', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(
important(designSystem, {}, '!border', {
contents: `let notBorder = !border\n`,
start: 16,
end: 16 + '!border'.length,
}),
).toEqual('!border')
})
test('does not match false positives with spaces at the end of the line', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(
important(designSystem, {}, '!border', {
contents: `let notBorder = !border \n`,
start: 16,
end: 16 + '!border'.length,
}),
).toEqual('!border')
})

View file

@ -19,9 +19,50 @@ export function important(
designSystem: DesignSystem,
_userConfig: Config,
rawCandidate: string,
location?: {
contents: string
start: number
end: number
},
): string {
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
// The important migration is one of the most broad migrations with a high
// potential of matching false positives since `!` is a valid character in
// most programming languages. Since v4 is technically backward compatible
// with v3 in that it can read `!` in the front of the utility too, we err
// on the side of caution and only migrate candidates that we are certain
// are inside of a string.
if (location) {
let isQuoteBeforeCandidate = false
for (let i = location.start - 1; i >= 0; i--) {
let char = location.contents.at(i)!
if (char === '\n') {
break
}
if (isQuote(char)) {
isQuoteBeforeCandidate = true
break
}
}
let isQuoteAfterCandidate = false
for (let i = location.end; i < location.contents.length; i++) {
let char = location.contents.at(i)!
if (char === '\n') {
break
}
if (isQuote(char)) {
isQuoteAfterCandidate = true
break
}
}
if (!isQuoteBeforeCandidate || !isQuoteAfterCandidate) {
continue
}
}
// The printCandidate function will already put the exclamation mark in
// the right place, so we just need to mark this candidate as requiring a
// migration.
@ -31,3 +72,14 @@ export function important(
return rawCandidate
}
function isQuote(char: string) {
switch (char) {
case '"':
case "'":
case '`':
return true
default:
return false
}
}

View file

@ -17,6 +17,11 @@ export type Migration = (
designSystem: DesignSystem,
userConfig: Config,
rawCandidate: string,
location?: {
contents: string
start: number
end: number
},
) => string
export const DEFAULT_MIGRATIONS: Migration[] = [
@ -34,9 +39,15 @@ export function migrateCandidate(
designSystem: DesignSystem,
userConfig: Config,
rawCandidate: string,
// Location is only set when migrating a candidate from a source file
location?: {
contents: string
start: number
end: number
},
): string {
for (let migration of DEFAULT_MIGRATIONS) {
rawCandidate = migration(designSystem, userConfig, rawCandidate)
rawCandidate = migration(designSystem, userConfig, rawCandidate, location)
}
return rawCandidate
}
@ -52,7 +63,11 @@ export default async function migrateContents(
let changes: StringChange[] = []
for (let { rawCandidate, start, end } of candidates) {
let migratedCandidate = migrateCandidate(designSystem, userConfig, rawCandidate)
let migratedCandidate = migrateCandidate(designSystem, userConfig, rawCandidate, {
contents,
start,
end,
})
if (migratedCandidate === rawCandidate) {
continue

View file

@ -5,6 +5,8 @@ import { Features, transform } from 'lightningcss'
import path from 'path'
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
const SPECIAL_QUERY_RE = /[?&](raw|url)\b/
export default function tailwindcss(): Plugin[] {
let servers: ViteDevServer[] = []
let config: ResolvedConfig | null = null
@ -261,9 +263,12 @@ function getExtension(id: string) {
function isPotentialCssRootFile(id: string) {
let extension = getExtension(id)
let isCssFile =
extension === 'css' ||
(extension === 'vue' && id.includes('&lang.css')) ||
(extension === 'astro' && id.includes('&lang.css'))
(extension === 'css' ||
(extension === 'vue' && id.includes('&lang.css')) ||
(extension === 'astro' && id.includes('&lang.css'))) &&
// Don't intercept special static asset resources
!SPECIAL_QUERY_RE.test(id)
return isCssFile
}

View file

@ -200,7 +200,7 @@ textarea,
input:where(:not([type='button'], [type='reset'], [type='submit'])),
select,
textarea {
border: 1px solid;
border-width: 1px;
}
/*

View file

@ -19,7 +19,6 @@
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.1",
"bun": "^1.1.29",
"vite": "catalog:",
"vite-plugin-handlebars": "^2.0.0"
"vite": "catalog:"
}
}

View file

@ -1 +0,0 @@
module.exports = require('tailwindcss-animate')

View file

@ -1,11 +1,7 @@
import { Foo } from './foo'
export function App() {
return (
<div className="m-3 p-3 border">
<h1 className="text-blue-500">Hello World</h1>
<button className="hocus:underline">Click me</button>
<Foo />
</div>
)
}

View file

@ -1,7 +0,0 @@
export function Bar() {
return (
<div>
<h2 className="text-red-500 underline">Bar</h2>
</div>
)
}

View file

@ -1,10 +0,0 @@
import { Bar } from './bar'
export function Foo() {
return (
<div>
<h2 className="text-blue-500">Foo</h2>
<Bar />
</div>
)
}

View file

@ -1 +0,0 @@
module.exports = require('@tailwindcss/forms')

View file

@ -1,2 +1 @@
@import 'tailwindcss';
@plugin "./plugin.js";

View file

@ -4,7 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>≈ Playground</title>
<link rel="stylesheet" href="./app.css" />
</head>
<body class="h-full">
<div id="app"></div>

View file

@ -2,6 +2,8 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './app'
import './index.css'
ReactDOM.createRoot(document.getElementById('app')!).render(
<React.StrictMode>
<App />

View file

@ -1,4 +0,0 @@
module.exports = function ({ addVariant }) {
addVariant('inverted', '@media (inverted-colors: inverted)')
addVariant('hocus', ['&:focus', '&:hover'])
}

View file

@ -1 +0,0 @@
module.exports = require('@tailwindcss/typography')

55
pnpm-lock.yaml generated
View file

@ -435,9 +435,6 @@ importers:
vite:
specifier: 'catalog:'
version: 5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6)
vite-plugin-handlebars:
specifier: ^2.0.0
version: 2.0.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6)
packages:
@ -1941,11 +1938,6 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
hasBin: true
has-bigints@1.0.2:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
@ -2352,9 +2344,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
next@14.1.0:
resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==}
engines: {node: '>=18.17.0'}
@ -3007,11 +2996,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uglify-js@3.19.1:
resolution: {integrity: sha512-y/2wiW+ceTYR2TSSptAhfnEtpLaQ4Ups5zrjB2d3kuVxHj16j/QJwPl5PvuGy9uARb39J0+iKxcRPvtpsx4A4A==}
engines: {node: '>=0.8.0'}
hasBin: true
unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
@ -3039,9 +3023,6 @@ packages:
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
vite-plugin-handlebars@2.0.0:
resolution: {integrity: sha512-+J3It0nyhPzx4nT1I+fnWH+jShTEXzm6X0Tgsggdm9IYFD7/eJ6a3ROI13HTe0CVoyaxm/fPxH5HDAKyfz7T0g==}
vite@5.4.0:
resolution: {integrity: sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -3133,9 +3114,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -4739,15 +4717,6 @@ snapshots:
graceful-fs@4.2.11: {}
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
source-map: 0.6.1
wordwrap: 1.0.0
optionalDependencies:
uglify-js: 3.19.1
has-bigints@1.0.2: {}
has-flag@3.0.0: {}
@ -5088,8 +5057,6 @@ snapshots:
natural-compare@1.4.0: {}
neo-async@2.6.2: {}
next@14.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.1.0
@ -5486,7 +5453,8 @@ snapshots:
source-map: 0.6.1
optional: true
source-map@0.6.1: {}
source-map@0.6.1:
optional: true
source-map@0.8.0-beta.0:
dependencies:
@ -5761,9 +5729,6 @@ snapshots:
typescript@5.5.4: {}
uglify-js@3.19.1:
optional: true
unbox-primitive@1.0.2:
dependencies:
call-bind: 1.0.7
@ -5805,20 +5770,6 @@ snapshots:
- supports-color
- terser
vite-plugin-handlebars@2.0.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6):
dependencies:
handlebars: 4.7.8
vite: 5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- terser
vite@5.4.0(@types/node@20.14.13)(lightningcss@1.26.0(patch_hash=5hwfyehqvg5wjb7mwtdvubqbl4))(terser@5.31.6):
dependencies:
esbuild: 0.21.5
@ -5920,8 +5871,6 @@ snapshots:
word-wrap@1.2.5: {}
wordwrap@1.0.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0