mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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:
parent
3f85b74611
commit
65240c9240
@ -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
|
||||
|
||||
@ -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',
|
||||
{
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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}` }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.')
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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])
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -54,6 +54,6 @@ test.each([
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = automaticVarInjection(designSystem, candidate)
|
||||
let migrated = automaticVarInjection(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -18,5 +18,5 @@ test.each([
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
expect(bgGradient(designSystem, candidate)).toEqual(result)
|
||||
expect(bgGradient(designSystem, {}, candidate)).toEqual(result)
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
116
packages/@tailwindcss-upgrade/src/template/codemods/prefix.ts
Normal file
116
packages/@tailwindcss-upgrade/src/template/codemods/prefix.ts
Normal 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]
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user