Template migrations: Migrate v3 prefixes to v4 (#14557)

This PR adds a new migration that can migrate Tailwind CSS v3 style
prefixes into Tailwind CSS v4.

The migration is split into three separate pieces of work:

1. Firstly, we need to read the full JavaScript config to get the _old_
prefix option. This is necessary because in v4, we will not allow things
like custom-separators for the prefix. From this option we will then try
and compute a new prefix (in 90% of the cases this is going to just
remove the trailing `-` but it can also work in more complex cases).
2. Then we migrate all Candidates. The important thing here is that we
need to operate on the raw candidate string because by relying on
`parseCandidate` (which we do for all other migrations) would not work,
as the candidates are not valid in v4 syntax. More on that in a bit.
3. Lastly we also make sure to update the CSS config to include the new
prefix. This is done by prepending the prefix option like so:
    
    ```css
    @import "tailwindcss" prefix(tw);
    ```

### Migrating candidates

The main difference between v3 prefixes and v4 prefixes is that in v3,
the prefix was _part of the utility_ where as in v4 it is _always in
front of the CSS class.

So, for example, this candidate in v3: 

```
hover:-tw-mr-4
```

Would be converted to the following in v4:

```
tw:hover:-mr-4
```

Since the first example _won't parse as a valid Candidate in v4, as the
`tw-mr` utility does not exist, we have to operate on the raw candidate
string first. To do this I created a fork of the `parseCandidate`
function _without any validation of utilities or variants_. This is used
to identify part of the candidate that is the `base` and then ensuring
the `base` starts with the old prefix. We then remove this to create an
"unprefixed" candidate that we validate against a version of the
DesignSystem _with no prefixes configured_. If the variant is valid this
way, we can then print it again with the `DesignSystem` that has the new
prefix to get the migrated version.

Since we set up the `DesignSystem` to include the new prefix, we can
also be certain that migrations that happen afterwards would still
disqualify candidates that aren't valid according to the new prefix
policy. This does mean we need to have the prefix fixup be the first
step in our pipeline.

One interesting bit is that in v3, arbitrary properties did not require
prefixes where as in v4 they do. So the following candidate:

```
[color:red]
```

Will be converted to:

```
tw:[color:red]
```
This commit is contained in:
Philipp Spiess 2024-10-01 18:04:08 +02:00 committed by GitHub
parent 3f85b74611
commit 65240c9240
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 509 additions and 91 deletions

View File

@ -13,8 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Expose timing information in debug mode ([#14553](https://github.com/tailwindlabs/tailwindcss/pull/14553))
- Add support for `blocklist` in config files ([#14556](https://github.com/tailwindlabs/tailwindcss/pull/14556))
- _Experimental_: Migrate `@import "tailwindcss/tailwind.css"` to `@import "tailwindcss"` ([#14514](https://github.com/tailwindlabs/tailwindcss/pull/14514))
- _Experimental_: Add template codemods for removal of automatic `var(…)` injection ([#14526](https://github.com/tailwindlabs/tailwindcss/pull/14526))
- _Experimental_: Add template codemods for migrating `bg-gradient-*` utilities to `bg-linear-*` ([#14537](https://github.com/tailwindlabs/tailwindcss/pull/14537]))
- _Experimental_: Add template codemods for migrating prefixes ([#14557](https://github.com/tailwindlabs/tailwindcss/pull/14557]))
- _Experimental_: Add template codemods for removal of automatic `var(…)` injection ([#14526](https://github.com/tailwindlabs/tailwindcss/pull/14526))
- _Experimental_: Add template codemods for migrating important utilities (e.g. `!flex` to `flex!`) ([#14502](https://github.com/tailwindlabs/tailwindcss/pull/14502))
### Fixed

View File

@ -43,6 +43,50 @@ test(
},
)
test(
`upgrades a v3 project with prefixes to v4`,
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js}'],
prefix: 'tw__',
}
`,
'src/index.html': html`
<h1>🤠👋</h1>
<div class="!tw__flex sm:!tw__block tw__bg-gradient-to-t flex [color:red]"></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade -c tailwind.config.js')
await fs.expectFileToContain(
'src/index.html',
html`
<h1>🤠👋</h1>
<div class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:[color:red]"></div>
`,
)
await fs.expectFileToContain('src/input.css', css`@import 'tailwindcss' prefix(tw);`)
},
)
test(
'migrate @apply',
{

View File

@ -37,7 +37,7 @@ export async function __unstable__loadDesignSystem(css: string, { base }: { base
})
}
async function loadModule(id: string, base: string, onDependency: (path: string) => void) {
export async function loadModule(id: string, base: string, onDependency: (path: string) => void) {
if (id[0] !== '.') {
let resolvedPath = await resolveJsId(id, base)
if (!resolvedPath) {

View File

@ -1,7 +1,7 @@
import * as Module from 'node:module'
import { pathToFileURL } from 'node:url'
import * as env from './env'
export * from './compile'
export { __unstable__loadDesignSystem, compile } from './compile'
export * from './normalize-path'
export { env }

View File

@ -31,6 +31,7 @@
"@tailwindcss/oxide": "workspace:^",
"enhanced-resolve": "^5.17.1",
"globby": "^14.0.2",
"jiti": "^2.0.0-beta.3",
"mri": "^1.2.0",
"picocolors": "^1.0.1",
"postcss": "^8.4.41",

View File

@ -6,9 +6,9 @@ import { migrateTailwindDirectives } from './migrate-tailwind-directives'
const css = dedent
function migrate(input: string) {
function migrate(input: string, options: { newPrefix?: string } = {}) {
return postcss()
.use(migrateTailwindDirectives())
.use(migrateTailwindDirectives(options))
.use(formatNodes())
.process(input, { from: expect.getState().testPath })
.then((result) => result.css)
@ -24,6 +24,21 @@ it("should not migrate `@import 'tailwindcss'`", async () => {
`)
})
it("should append a prefix to `@import 'tailwindcss'`", async () => {
expect(
await migrate(
css`
@import 'tailwindcss';
`,
{
newPrefix: 'tw',
},
),
).toEqual(css`
@import 'tailwindcss' prefix(tw);
`)
})
it('should migrate the tailwind.css import', async () => {
expect(
await migrate(css`
@ -34,6 +49,21 @@ it('should migrate the tailwind.css import', async () => {
`)
})
it('should migrate the tailwind.css import with a prefix', async () => {
expect(
await migrate(
css`
@import 'tailwindcss/tailwind.css';
`,
{
newPrefix: 'tw',
},
),
).toEqual(css`
@import 'tailwindcss' prefix(tw);
`)
})
it('should migrate the default @tailwind directives to a single import', async () => {
expect(
await migrate(css`
@ -46,6 +76,23 @@ it('should migrate the default @tailwind directives to a single import', async (
`)
})
it('should migrate the default @tailwind directives to a single import with a prefix', async () => {
expect(
await migrate(
css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
{
newPrefix: 'tw',
},
),
).toEqual(css`
@import 'tailwindcss' prefix(tw);
`)
})
it('should migrate the default @tailwind directives as imports to a single import', async () => {
expect(
await migrate(css`
@ -64,7 +111,7 @@ it('should migrate the default @tailwind directives to a single import in a vali
@charset "UTF-8";
@layer foo, bar, baz;
/**!
/**!
* License header
*/
@ -84,7 +131,7 @@ it('should migrate the default @tailwind directives to a single import in a vali
@charset "UTF-8";
@layer foo, bar, baz;
/**!
/**!
* License header
*/
@ -102,7 +149,7 @@ it('should migrate the default @tailwind directives as imports to a single impor
@charset "UTF-8";
@layer foo, bar, baz;
/**!
/**!
* License header
*/
@ -114,7 +161,7 @@ it('should migrate the default @tailwind directives as imports to a single impor
@charset "UTF-8";
@layer foo, bar, baz;
/**!
/**!
* License header
*/
@ -122,6 +169,23 @@ it('should migrate the default @tailwind directives as imports to a single impor
`)
})
it('should migrate the default @tailwind directives as imports to a single import with a prefix', async () => {
expect(
await migrate(
css`
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
`,
{
newPrefix: 'tw',
},
),
).toEqual(css`
@import 'tailwindcss' prefix(tw);
`)
})
it.each([
[
// The default order
@ -213,6 +277,22 @@ it('should migrate `@tailwind base` to theme and preflight imports', async () =>
`)
})
it('should migrate `@tailwind base` to theme and preflight imports with a prefix', async () => {
expect(
await migrate(
css`
@tailwind base;
`,
{
newPrefix: 'tw',
},
),
).toEqual(css`
@import 'tailwindcss/theme' layer(theme) prefix(tw);
@import 'tailwindcss/preflight' layer(base);
`)
})
it('should migrate `@import "tailwindcss/base"` to theme and preflight imports', async () => {
expect(
await migrate(css`
@ -224,6 +304,22 @@ it('should migrate `@import "tailwindcss/base"` to theme and preflight imports',
`)
})
it('should migrate `@import "tailwindcss/base"` to theme and preflight imports with a prefix', async () => {
expect(
await migrate(
css`
@import 'tailwindcss/base';
`,
{
newPrefix: 'tw',
},
),
).toEqual(css`
@import 'tailwindcss/theme' layer(theme) prefix(tw);
@import 'tailwindcss/preflight' layer(base);
`)
})
it('should migrate `@tailwind utilities` to an import', async () => {
expect(
await migrate(css`
@ -273,6 +369,22 @@ it('should migrate `@tailwind base` and `@tailwind utilities` to a single import
`)
})
it('should migrate `@tailwind base` and `@tailwind utilities` to a single import with a prefix', async () => {
expect(
await migrate(
css`
@import 'tailwindcss/base';
@import 'tailwindcss/utilities';
`,
{
newPrefix: 'tw',
},
),
).toEqual(css`
@import 'tailwindcss' prefix(tw);
`)
})
it('should drop `@tailwind screens;`', async () => {
expect(
await migrate(css`

View File

@ -2,7 +2,9 @@ import { AtRule, type ChildNode, type Plugin, type Root } from 'postcss'
const DEFAULT_LAYER_ORDER = ['theme', 'base', 'components', 'utilities']
export function migrateTailwindDirectives(): Plugin {
export function migrateTailwindDirectives(options: { newPrefix?: string }): Plugin {
let prefixParams = options.newPrefix ? ` prefix(${options.newPrefix})` : ''
function migrate(root: Root) {
let baseNode = null as AtRule | null
let utilitiesNode = null as AtRule | null
@ -21,6 +23,11 @@ export function migrateTailwindDirectives(): Plugin {
node.params = node.params.replace('tailwindcss/tailwind.css', 'tailwindcss')
}
// Append any new prefix() param to existing `@import 'tailwindcss'` directives
if (node.name === 'import' && node.params.match(/^["']tailwindcss["']/)) {
node.params += prefixParams
}
// Track old imports and directives
else if (
(node.name === 'tailwind' && node.params === 'base') ||
@ -52,7 +59,9 @@ export function migrateTailwindDirectives(): Plugin {
// Insert default import if all directives are present
if (baseNode !== null && utilitiesNode !== null) {
if (!defaultImportNode) {
findTargetNode(orderedNodes).before(new AtRule({ name: 'import', params: "'tailwindcss'" }))
findTargetNode(orderedNodes).before(
new AtRule({ name: 'import', params: `'tailwindcss'${prefixParams}` }),
)
}
baseNode?.remove()
utilitiesNode?.remove()
@ -69,7 +78,7 @@ export function migrateTailwindDirectives(): Plugin {
} else if (baseNode !== null) {
if (!themeImportNode) {
findTargetNode(orderedNodes).before(
new AtRule({ name: 'import', params: "'tailwindcss/theme' layer(theme)" }),
new AtRule({ name: 'import', params: `'tailwindcss/theme' layer(theme)${prefixParams}` }),
)
}

View File

@ -15,6 +15,7 @@ it('should print the input as-is', async () => {
/* below */
}
`,
{},
expect.getState().testPath,
),
).toMatchInlineSnapshot(`
@ -29,41 +30,44 @@ it('should print the input as-is', async () => {
it('should migrate a stylesheet', async () => {
expect(
await migrateContents(css`
@tailwind base;
await migrateContents(
css`
@tailwind base;
html {
overflow: hidden;
}
@tailwind components;
.a {
z-index: 1;
}
@layer components {
.b {
z-index: 2;
html {
overflow: hidden;
}
}
.c {
z-index: 3;
}
@tailwind components;
@tailwind utilities;
.d {
z-index: 4;
}
@layer utilities {
.e {
z-index: 5;
.a {
z-index: 1;
}
}
`),
@layer components {
.b {
z-index: 2;
}
}
.c {
z-index: 3;
}
@tailwind utilities;
.d {
z-index: 4;
}
@layer utilities {
.e {
z-index: 5;
}
}
`,
{},
),
).toMatchInlineSnapshot(`
"@import 'tailwindcss';
@ -103,14 +107,17 @@ it('should migrate a stylesheet', async () => {
it('should migrate a stylesheet (with imports)', async () => {
expect(
await migrateContents(css`
@import 'tailwindcss/base';
@import './my-base.css';
@import 'tailwindcss/components';
@import './my-components.css';
@import 'tailwindcss/utilities';
@import './my-utilities.css';
`),
await migrateContents(
css`
@import 'tailwindcss/base';
@import './my-base.css';
@import 'tailwindcss/components';
@import './my-components.css';
@import 'tailwindcss/utilities';
@import './my-utilities.css';
`,
{},
),
).toMatchInlineSnapshot(`
"@import 'tailwindcss';
@import './my-base.css' layer(base);
@ -121,17 +128,20 @@ it('should migrate a stylesheet (with imports)', async () => {
it('should migrate a stylesheet (with preceding rules that should be wrapped in an `@layer`)', async () => {
expect(
await migrateContents(css`
@charset "UTF-8";
@layer foo, bar, baz;
/**! My license comment */
html {
color: red;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
`),
await migrateContents(
css`
@charset "UTF-8";
@layer foo, bar, baz;
/**! My license comment */
html {
color: red;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
{},
),
).toMatchInlineSnapshot(`
"@charset "UTF-8";
@layer foo, bar, baz;

View File

@ -2,6 +2,7 @@
import { globby } from 'globby'
import path from 'node:path'
import type { Config } from 'tailwindcss'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { help } from './commands/help'
import { migrate as migrateStylesheet } from './migrate'
@ -44,6 +45,8 @@ async function run() {
let parsedConfig: {
designSystem: DesignSystem
globs: { pattern: string; base: string }[]
userConfig: Config
newPrefix: string | null
} | null = null
if (flags['--config']) {
try {
@ -76,7 +79,11 @@ async function run() {
files.sort()
// Migrate each file
await Promise.allSettled(files.map((file) => migrateTemplate(parsedConfig.designSystem, file)))
await Promise.allSettled(
files.map((file) =>
migrateTemplate(parsedConfig.designSystem, parsedConfig.userConfig, file),
),
)
success('Template migration complete.')
}
@ -103,7 +110,11 @@ async function run() {
files = files.filter((file) => file.endsWith('.css'))
// Migrate each file
await Promise.allSettled(files.map((file) => migrateStylesheet(file)))
await Promise.allSettled(
files.map((file) =>
migrateStylesheet(file, { newPrefix: parsedConfig?.newPrefix ?? undefined }),
),
)
success('Stylesheet migration complete.')
}

View File

@ -7,20 +7,24 @@ import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
export async function migrateContents(contents: string, file?: string) {
export interface MigrateOptions {
newPrefix?: string
}
export async function migrateContents(contents: string, options: MigrateOptions, file?: string) {
return postcss()
.use(migrateAtApply())
.use(migrateAtLayerUtilities())
.use(migrateMissingLayers())
.use(migrateTailwindDirectives())
.use(migrateTailwindDirectives(options))
.use(formatNodes())
.process(contents, { from: file })
.then((result) => result.css)
}
export async function migrate(file: string) {
export async function migrate(file: string, options: MigrateOptions) {
let fullPath = path.resolve(process.cwd(), file)
let contents = await fs.readFile(fullPath, 'utf-8')
await fs.writeFile(fullPath, await migrateContents(contents, fullPath))
await fs.writeFile(fullPath, await migrateContents(contents, options, fullPath))
}

View File

@ -132,7 +132,7 @@ for (let variant of variants) {
}
}
describe('toString()', () => {
describe('printCandidate()', () => {
test.each(combinations)('%s', async (candidate: string, result: string) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
@ -142,7 +142,7 @@ describe('toString()', () => {
// Sometimes we will have a functional and a static candidate for the same
// raw input string (e.g. `-inset-full`). Dedupe in this case.
let cleaned = new Set([...candidates].map(printCandidate))
let cleaned = new Set([...candidates].map((c) => printCandidate(designSystem, c)))
expect([...cleaned]).toEqual([result])
})

View File

@ -1,6 +1,7 @@
import { Scanner } from '@tailwindcss/oxide'
import stringByteSlice from 'string-byte-slice'
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
export async function extractRawCandidates(
content: string,
@ -15,14 +16,18 @@ export async function extractRawCandidates(
return candidates
}
export function printCandidate(candidate: Candidate | null) {
if (candidate === null) return 'null'
export function printCandidate(designSystem: DesignSystem, candidate: Candidate) {
let parts: string[] = []
for (let variant of candidate.variants) {
parts.unshift(printVariant(variant))
}
// Handle prefix
if (designSystem.theme.prefix) {
parts.unshift(designSystem.theme.prefix)
}
let base: string = ''
// Handle negative

View File

@ -54,6 +54,6 @@ test.each([
base: __dirname,
})
let migrated = automaticVarInjection(designSystem, candidate)
let migrated = automaticVarInjection(designSystem, {}, candidate)
expect(migrated).toEqual(result)
})

View File

@ -1,9 +1,14 @@
import type { Config } from 'tailwindcss'
import { walk, WalkAction } from '../../../../tailwindcss/src/ast'
import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from '../candidates'
export function automaticVarInjection(designSystem: DesignSystem, rawCandidate: string): string {
export function automaticVarInjection(
designSystem: DesignSystem,
_userConfig: Config,
rawCandidate: string,
): string {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
let didChange = false
@ -57,7 +62,7 @@ export function automaticVarInjection(designSystem: DesignSystem, rawCandidate:
}
if (didChange) {
return printCandidate(candidate)
return printCandidate(designSystem, candidate)
}
}
return rawCandidate

View File

@ -18,5 +18,5 @@ test.each([
base: __dirname,
})
expect(bgGradient(designSystem, candidate)).toEqual(result)
expect(bgGradient(designSystem, {}, candidate)).toEqual(result)
})

View File

@ -1,9 +1,14 @@
import type { Config } from 'tailwindcss'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from '../candidates'
const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl']
export function bgGradient(designSystem: DesignSystem, rawCandidate: string): string {
export function bgGradient(
designSystem: DesignSystem,
_userConfig: Config,
rawCandidate: string,
): string {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) {
let direction = candidate.root.slice(15)
@ -13,7 +18,7 @@ export function bgGradient(designSystem: DesignSystem, rawCandidate: string): st
}
candidate.root = `bg-linear-to-${direction}`
return printCandidate(candidate)
return printCandidate(designSystem, candidate)
}
}
return rawCandidate

View File

@ -1,10 +1,7 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import dedent from 'dedent'
import { expect, test } from 'vitest'
import { important } from './important'
let html = dedent
test.each([
['!flex', 'flex!'],
['min-[calc(1000px+12em)]:!flex', 'min-[calc(1000px_+_12em)]:flex!'],
@ -18,5 +15,5 @@ test.each([
base: __dirname,
})
expect(important(designSystem, candidate)).toEqual(result)
expect(important(designSystem, {}, candidate)).toEqual(result)
})

View File

@ -1,3 +1,4 @@
import type { Config } from 'tailwindcss'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from '../candidates'
@ -13,13 +14,17 @@ import { printCandidate } from '../candidates'
// Should turn into:
//
// flex! md:block!
export function important(designSystem: DesignSystem, rawCandidate: string): string {
export function important(
designSystem: DesignSystem,
_userConfig: Config,
rawCandidate: string,
): string {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
// 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.
return printCandidate(candidate)
return printCandidate(designSystem, candidate)
}
}

View File

@ -0,0 +1,48 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import { prefix } from './prefix'
describe('for projects with configured prefix', () => {
test.each([
['tw-flex', 'tw:flex'],
['-tw-mr-4', 'tw:-mr-4'],
['!tw-flex', 'tw:flex!'],
['tw-text-red-500/50', 'tw:text-red-500/50'],
// With variants
['hover:tw-flex', 'tw:hover:flex'],
['hover:-tw-mr-4', 'tw:hover:-mr-4'],
['hover:!tw-flex', 'tw:hover:flex!'],
// Does not change un-prefixed candidates
['flex', 'flex'],
['hover:flex', 'hover:flex'],
// Adds prefix to arbitrary candidates
['[color:red]', 'tw:[color:red]'],
])('%s => %s', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss" prefix(tw);', {
base: __dirname,
})
expect(prefix(designSystem, { prefix: 'tw-' }, candidate)).toEqual(result)
})
})
test('can handle complex prefix separators', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss" prefix(tw);', {
base: __dirname,
})
expect(prefix(designSystem, { prefix: 'tw__' }, 'tw__flex')).toEqual('tw:flex')
})
describe('for projects without configured prefix', () => {
test('ignores candidates with prefixes', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(prefix(designSystem, {}, 'tw-flex')).toEqual('tw-flex')
})
})

View File

@ -0,0 +1,116 @@
import type { Config } from 'tailwindcss'
import type { Candidate } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { printCandidate } from '../candidates'
export function prefix(
designSystem: DesignSystem,
userConfig: Config,
rawCandidate: string,
): string {
if (!designSystem.theme.prefix) return rawCandidate
let v3Base = extractV3Base(designSystem, userConfig, rawCandidate)
if (!v3Base) return rawCandidate
// Only migrate candidates which are valid in v4
let originalPrefix = designSystem.theme.prefix
let candidate: Candidate | null = null
try {
designSystem.theme.prefix = null
let unprefixedCandidate =
rawCandidate.slice(0, v3Base.start) + v3Base.base + rawCandidate.slice(v3Base.end)
let candidates = designSystem.parseCandidate(unprefixedCandidate)
if (candidates.length > 0) {
candidate = candidates[0]
}
} finally {
designSystem.theme.prefix = originalPrefix
}
if (!candidate) return rawCandidate
return printCandidate(designSystem, candidate)
}
// Parses a raw candidate with v3 compatible prefix syntax. This won't match if
// the `base` part of the candidate does not match the configured prefix, unless
// a bare candidate is used.
function extractV3Base(
designSystem: DesignSystem,
userConfig: Config,
rawCandidate: string,
): { base: string; start: number; end: number } | null {
if (!designSystem.theme.prefix) return null
if (!userConfig.prefix)
throw new Error(
'Could not find the Tailwind CSS v3 `prefix` configuration inside the JavaScript config.',
)
// hover:focus:underline
// ^^^^^ ^^^^^^ -> Variants
// ^^^^^^^^^ -> Base
let rawVariants = segment(rawCandidate, ':')
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because even if the `input` was an empty string, splitting an
// empty string by `:` will always result in an array with at least one
// element.
let base = rawVariants.pop()!
let start = rawCandidate.length - base.length
let end = start + base.length
let important = false
let negative = false
// Candidates that end with an exclamation mark are the important version with
// higher specificity of the non-important candidate, e.g. `mx-4!`.
if (base[base.length - 1] === '!') {
important = true
base = base.slice(0, -1)
}
// Legacy syntax with leading `!`, e.g. `!mx-4`.
else if (base[0] === '!') {
important = true
base = base.slice(1)
}
// Candidates that start with a dash are the negative versions of another
// candidate, e.g. `-mx-4`.
if (base[0] === '-') {
negative = true
base = base.slice(1)
}
if (!base.startsWith(userConfig.prefix) && base[0] !== '[') {
return null
} else {
if (base[0] !== '[') base = base.slice(userConfig.prefix.length)
if (negative) base = '-' + base
if (important) base += '!'
return {
base,
start,
end,
}
}
}
const VALID_PREFIX = /([a-z]+)/
export function migratePrefix(prefix: string): string {
let result = VALID_PREFIX.exec(prefix.toLocaleLowerCase())
if (!result) {
console.warn(
`The prefix "${prefix} can not be used with Tailwind CSS v4 and cannot be converted to a valid one automatically. We've updated it to "tw" for you.`,
)
return 'tw'
}
return result[0]
}

View File

@ -1,17 +1,24 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Config } from 'tailwindcss'
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
import { extractRawCandidates, replaceCandidateInContent } from './candidates'
import { automaticVarInjection } from './codemods/automatic-var-injection'
import { bgGradient } from './codemods/bg-gradient'
import { important } from './codemods/important'
import { prefix } from './codemods/prefix'
export type Migration = (designSystem: DesignSystem, rawCandidate: string) => string
export type Migration = (
designSystem: DesignSystem,
userConfig: Config,
rawCandidate: string,
) => string
export default async function migrateContents(
designSystem: DesignSystem,
userConfig: Config,
contents: string,
migrations: Migration[] = [important, automaticVarInjection, bgGradient],
migrations: Migration[] = [prefix, important, automaticVarInjection, bgGradient],
): Promise<string> {
let candidates = await extractRawCandidates(contents)
@ -22,7 +29,7 @@ export default async function migrateContents(
for (let { rawCandidate, start, end } of candidates) {
let needsMigration = false
for (let migration of migrations) {
let candidate = migration(designSystem, rawCandidate)
let candidate = migration(designSystem, userConfig, rawCandidate)
if (rawCandidate !== candidate) {
rawCandidate = candidate
needsMigration = true
@ -37,9 +44,9 @@ export default async function migrateContents(
return output
}
export async function migrate(designSystem: DesignSystem, file: string) {
export async function migrate(designSystem: DesignSystem, userConfig: Config, file: string) {
let fullPath = path.resolve(process.cwd(), file)
let contents = await fs.readFile(fullPath, 'utf-8')
await fs.writeFile(fullPath, await migrateContents(designSystem, contents))
await fs.writeFile(fullPath, await migrateContents(designSystem, userConfig, contents))
}

View File

@ -1,16 +1,28 @@
import { __unstable__loadDesignSystem, compile } from '@tailwindcss/node'
import path from 'node:path'
import { dirname } from 'path'
import type { Config } from 'tailwindcss'
import { fileURLToPath } from 'url'
import { loadModule } from '../../../@tailwindcss-node/src/compile'
import { resolveConfig } from '../../../tailwindcss/src/compat/config/resolve-config'
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
import { migratePrefix } from './codemods/prefix'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
let css = String.raw
export async function parseConfig(
configPath: string,
options: { base: string },
): Promise<{ designSystem: DesignSystem; globs: { base: string; pattern: string }[] }> {
): Promise<{
designSystem: DesignSystem
globs: { base: string; pattern: string }[]
userConfig: Config
newPrefix: string | null
}> {
// We create a relative path from the current file to the config file. This is
// required so that the base for Tailwind CSS can bet inside the
// @tailwindcss-upgrade package and we can require `tailwindcss` properly.
@ -24,11 +36,34 @@ export async function parseConfig(
relative = './' + relative
}
let input = `@import 'tailwindcss';\n@config './${relative}'`
let userConfig = await createResolvedUserConfig(fullConfigPath)
let newPrefix = userConfig.prefix ? migratePrefix(userConfig.prefix) : null
let input = css`
@import 'tailwindcss' ${newPrefix ? `prefix(${newPrefix})` : ''};
@config './${relative}';
`
let [compiler, designSystem] = await Promise.all([
compile(input, { base: __dirname, onDependency: () => {} }),
__unstable__loadDesignSystem(input, { base: __dirname }),
])
return { designSystem, globs: compiler.globs }
return { designSystem, globs: compiler.globs, userConfig, newPrefix }
}
async function createResolvedUserConfig(fullConfigPath: string): Promise<Config> {
let [noopDesignSystem, unresolvedUserConfig] = await Promise.all([
__unstable__loadDesignSystem(
css`
@import 'tailwindcss';
`,
{ base: __dirname },
),
loadModule(fullConfigPath, __dirname, () => {}).then((result) => result.module) as Config,
])
return resolveConfig(noopDesignSystem, [
{ base: dirname(fullConfigPath), config: unresolvedUserConfig },
]) as any
}

3
pnpm-lock.yaml generated
View File

@ -282,6 +282,9 @@ importers:
globby:
specifier: ^14.0.2
version: 14.0.2
jiti:
specifier: ^2.0.0-beta.3
version: 2.0.0-beta.3
mri:
specifier: ^1.2.0
version: 1.2.0