mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Migrate utilities in CSS files imported into layers (#14617)
When a stylesheet is imported with `@import “…” layer(utilities)` that means that all classes in that stylesheet and any of its imported stylesheets become candidates for `@utility` conversion. Doing this correctly requires us to place `@utility` rules into separate stylesheets (usually) and replicate the import tree without layers as `@utility` MUST be root-level. If a file consists of only utilities we won't create a separate file for it and instead place the `@utility` rules in the same stylesheet. Been doing a LOT of pairing with @RobinMalfait on this one but I think this is finally ready to be looked at --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
parent
75b906643c
commit
4d1becd2f9
@ -32,15 +32,15 @@ test(
|
||||
async ({ exec, fs }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
await fs.expectFileToContain(
|
||||
'src/index.html',
|
||||
html`
|
||||
<h1>🤠👋</h1>
|
||||
<div class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)]"></div>
|
||||
`,
|
||||
)
|
||||
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.html ---
|
||||
<h1>🤠👋</h1>
|
||||
<div class="flex! sm:block! bg-linear-to-t bg-[var(--my-red)]"></div>
|
||||
|
||||
await fs.expectFileToContain('src/input.css', css`@import 'tailwindcss';`)
|
||||
--- ./src/input.css ---
|
||||
@import 'tailwindcss';"
|
||||
`)
|
||||
|
||||
let packageJsonContent = await fs.read('package.json')
|
||||
let packageJson = JSON.parse(packageJsonContent)
|
||||
@ -86,23 +86,19 @@ test(
|
||||
async ({ exec, fs }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
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>
|
||||
`,
|
||||
)
|
||||
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.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); `)
|
||||
await fs.expectFileToContain(
|
||||
'src/input.css',
|
||||
css`
|
||||
.btn {
|
||||
@apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white;
|
||||
}
|
||||
`,
|
||||
)
|
||||
--- ./src/input.css ---
|
||||
@import 'tailwindcss' prefix(tw);
|
||||
|
||||
.btn {
|
||||
@apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white;
|
||||
}"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
@ -139,22 +135,23 @@ test(
|
||||
async ({ fs, exec }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
await fs.expectFileToContain(
|
||||
'src/index.css',
|
||||
css`
|
||||
.a {
|
||||
@apply flex;
|
||||
}
|
||||
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.css ---
|
||||
@import 'tailwindcss';
|
||||
|
||||
.b {
|
||||
@apply flex!;
|
||||
}
|
||||
.a {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.c {
|
||||
@apply flex! flex-col! items-center!;
|
||||
}
|
||||
`,
|
||||
)
|
||||
.b {
|
||||
@apply flex!;
|
||||
}
|
||||
|
||||
.c {
|
||||
@apply flex! flex-col! items-center!;
|
||||
}"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
@ -191,27 +188,23 @@ test(
|
||||
async ({ fs, exec }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
|
||||
await fs.expectFileToContain(
|
||||
'src/index.css',
|
||||
css`
|
||||
@layer base {
|
||||
html {
|
||||
color: #333;
|
||||
}
|
||||
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.css ---
|
||||
@import 'tailwindcss';
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color: #333;
|
||||
}
|
||||
`,
|
||||
)
|
||||
await fs.expectFileToContain(
|
||||
'src/index.css',
|
||||
css`
|
||||
@layer components {
|
||||
.btn {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
color: red;
|
||||
}
|
||||
`,
|
||||
)
|
||||
}"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
@ -253,22 +246,23 @@ test(
|
||||
async ({ fs, exec }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
await fs.expectFileToContain(
|
||||
'src/index.css',
|
||||
css`
|
||||
@utility btn {
|
||||
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
|
||||
}
|
||||
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.css ---
|
||||
@import 'tailwindcss';
|
||||
|
||||
@utility no-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@utility btn {
|
||||
@apply rounded-md px-2 py-1 bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
)
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
@ -533,12 +527,14 @@ test(
|
||||
async ({ exec, fs }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
await fs.expectFileToContain('src/index.html', html`
|
||||
<div class="flex"></div>
|
||||
`)
|
||||
await fs.expectFileToContain('src/other.html', html`
|
||||
<div class="tw:flex"></div>
|
||||
`)
|
||||
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.html ---
|
||||
<div class="flex"></div>
|
||||
|
||||
--- ./src/other.html ---
|
||||
<div class="tw:flex"></div>"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
@ -571,18 +567,309 @@ test(
|
||||
async ({ exec, fs }) => {
|
||||
await exec('npx @tailwindcss/upgrade')
|
||||
|
||||
await fs.expectFileToContain(
|
||||
'src/index.html',
|
||||
html`
|
||||
<div class="tw:bg-linear-to-t"></div>
|
||||
`,
|
||||
)
|
||||
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.html ---
|
||||
<div class="tw:bg-linear-to-t"></div>
|
||||
|
||||
await fs.expectFileToContain(
|
||||
'src/other.html',
|
||||
html`
|
||||
<div class="bg-gradient-to-t"></div>
|
||||
`,
|
||||
)
|
||||
--- ./src/other.html ---
|
||||
<div class="bg-gradient-to-t"></div>"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'migrate utilities in an imported file',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "workspace:^",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tailwind.config.js': js`module.exports = {}`,
|
||||
'src/index.css': css`
|
||||
@import 'tailwindcss';
|
||||
@import './utilities.css' layer(utilities);
|
||||
`,
|
||||
'src/utilities.css': css`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ fs, exec }) => {
|
||||
await exec('npx @tailwindcss/upgrade --force')
|
||||
|
||||
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.css ---
|
||||
@import 'tailwindcss';
|
||||
@import './utilities.css';
|
||||
|
||||
--- ./src/utilities.css ---
|
||||
@utility no-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'migrate utilities in deep import trees',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "workspace:^",
|
||||
"@tailwindcss/cli": "workspace:^",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tailwind.config.js': js`module.exports = {}`,
|
||||
'src/index.html': html`
|
||||
<div class="hover:thing"></div>
|
||||
`,
|
||||
'src/index.css': css`
|
||||
@import 'tailwindcss/utilities';
|
||||
@import './a.1.css' layer(utilities);
|
||||
@import './b.1.css' layer(components);
|
||||
@import './c.1.css';
|
||||
@import './d.1.css';
|
||||
`,
|
||||
'src/a.1.css': css`
|
||||
@import './a.1.utilities.css';
|
||||
|
||||
.foo-from-a {
|
||||
color: red;
|
||||
}
|
||||
`,
|
||||
'src/a.1.utilities.css': css`
|
||||
#foo {
|
||||
--keep: me;
|
||||
}
|
||||
|
||||
.foo-from-import {
|
||||
color: blue;
|
||||
}
|
||||
`,
|
||||
'src/b.1.css': css`
|
||||
@import './b.1.components.css';
|
||||
|
||||
.bar-from-b {
|
||||
color: red;
|
||||
}
|
||||
`,
|
||||
'src/b.1.components.css': css`
|
||||
.bar-from-import {
|
||||
color: blue;
|
||||
}
|
||||
`,
|
||||
'src/c.1.css': css`
|
||||
@import './c.2.css' layer(utilities);
|
||||
.baz-from-c {
|
||||
color: green;
|
||||
}
|
||||
`,
|
||||
'src/c.2.css': css`
|
||||
@import './c.3.css';
|
||||
#baz {
|
||||
--keep: me;
|
||||
}
|
||||
.baz-from-import {
|
||||
color: yellow;
|
||||
}
|
||||
`,
|
||||
'src/c.3.css': css`
|
||||
#baz {
|
||||
--keep: me;
|
||||
}
|
||||
.baz-from-import {
|
||||
color: yellow;
|
||||
}
|
||||
`,
|
||||
|
||||
// This is a super deep import chain
|
||||
// And no `*.utilities.css` files should be created for these
|
||||
// because there are no rules that need to be separated
|
||||
'src/d.1.css': css`@import './d.2.css' layer(utilities);`,
|
||||
'src/d.2.css': css`@import './d.3.css';`,
|
||||
'src/d.3.css': css`@import './d.4.css';`,
|
||||
'src/d.4.css': css`
|
||||
.from-a-4 {
|
||||
color: blue;
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ fs, exec }) => {
|
||||
await exec('npx @tailwindcss/upgrade --force')
|
||||
|
||||
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/index.css ---
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
@import './a.1.css' layer(utilities);
|
||||
@import './a.1.utilities.1.css';
|
||||
@import './b.1.css';
|
||||
@import './c.1.css' layer(utilities);
|
||||
@import './c.1.utilities.css';
|
||||
@import './d.1.css';
|
||||
|
||||
--- ./src/a.1.css ---
|
||||
@import './a.1.utilities.css'
|
||||
|
||||
--- ./src/a.1.utilities.1.css ---
|
||||
@import './a.1.utilities.utilities.css';
|
||||
@utility foo-from-a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
--- ./src/a.1.utilities.css ---
|
||||
#foo {
|
||||
--keep: me;
|
||||
}
|
||||
|
||||
--- ./src/a.1.utilities.utilities.css ---
|
||||
@utility foo-from-import {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
--- ./src/b.1.components.css ---
|
||||
@utility bar-from-import {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
--- ./src/b.1.css ---
|
||||
@import './b.1.components.css';
|
||||
@utility bar-from-b {
|
||||
color: red;
|
||||
}
|
||||
|
||||
--- ./src/c.1.css ---
|
||||
@import './c.2.css' layer(utilities);
|
||||
.baz-from-c {
|
||||
color: green;
|
||||
}
|
||||
|
||||
--- ./src/c.1.utilities.css ---
|
||||
@import './c.2.utilities.css'
|
||||
|
||||
--- ./src/c.2.css ---
|
||||
@import './c.3.css';
|
||||
#baz {
|
||||
--keep: me;
|
||||
}
|
||||
|
||||
--- ./src/c.2.utilities.css ---
|
||||
@import './c.3.utilities.css';
|
||||
@utility baz-from-import {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
--- ./src/c.3.css ---
|
||||
#baz {
|
||||
--keep: me;
|
||||
}
|
||||
|
||||
--- ./src/c.3.utilities.css ---
|
||||
@utility baz-from-import {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
--- ./src/d.1.css ---
|
||||
@import './d.2.css'
|
||||
|
||||
--- ./src/d.2.css ---
|
||||
@import './d.3.css'
|
||||
|
||||
--- ./src/d.3.css ---
|
||||
@import './d.4.css'
|
||||
|
||||
--- ./src/d.4.css ---
|
||||
@utility from-a-4 {
|
||||
color: blue;
|
||||
}"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'migrate utility files imported by multiple roots',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "workspace:^",
|
||||
"@tailwindcss/cli": "workspace:^",
|
||||
"@tailwindcss/upgrade": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tailwind.config.js': js`module.exports = {}`,
|
||||
'src/index.html': html`
|
||||
<div class="hover:thing"></div>
|
||||
`,
|
||||
'src/root.1.css': css`
|
||||
@import 'tailwindcss/utilities';
|
||||
@import './a.1.css' layer(utilities);
|
||||
`,
|
||||
'src/root.2.css': css`
|
||||
@import 'tailwindcss/utilities';
|
||||
@import './a.1.css' layer(components);
|
||||
`,
|
||||
'src/root.3.css': css`
|
||||
@import 'tailwindcss/utilities';
|
||||
@import './a.1.css';
|
||||
`,
|
||||
'src/a.1.css': css`
|
||||
.foo-from-a {
|
||||
color: red;
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ fs, exec }) => {
|
||||
let output = await exec('npx @tailwindcss/upgrade --force')
|
||||
|
||||
expect(output).toMatch(
|
||||
/You have one or more stylesheets that are imported into a utility layer and non-utility layer./,
|
||||
)
|
||||
|
||||
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
|
||||
"
|
||||
--- ./src/a.1.css ---
|
||||
.foo-from-a {
|
||||
color: red;
|
||||
}
|
||||
|
||||
--- ./src/root.1.css ---
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
@import './a.1.css' layer(utilities);
|
||||
|
||||
--- ./src/root.2.css ---
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
@import './a.1.css' layer(components);
|
||||
|
||||
--- ./src/root.3.css ---
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
||||
@import './a.1.css' layer(utilities);"
|
||||
`)
|
||||
},
|
||||
)
|
||||
|
||||
@ -41,6 +41,7 @@ interface TestContext {
|
||||
write(filePath: string, content: string): Promise<void>
|
||||
read(filePath: string): Promise<string>
|
||||
glob(pattern: string): Promise<[string, string][]>
|
||||
dumpFiles(pattern: string): Promise<string>
|
||||
expectFileToContain(
|
||||
filePath: string,
|
||||
contents: string | string[] | RegExp | RegExp[],
|
||||
@ -113,7 +114,7 @@ export function test(
|
||||
if (execOptions.ignoreStdErr !== true) console.error(stderr)
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(stdout.toString())
|
||||
resolve(stdout.toString() + '\n\n' + stderr.toString())
|
||||
}
|
||||
},
|
||||
)
|
||||
@ -306,6 +307,31 @@ export function test(
|
||||
}),
|
||||
)
|
||||
},
|
||||
async dumpFiles(pattern: string) {
|
||||
let files = await context.fs.glob(pattern)
|
||||
return `\n${files
|
||||
.slice()
|
||||
.sort((a: [string], z: [string]) => {
|
||||
let aParts = a[0].split('/')
|
||||
let zParts = z[0].split('/')
|
||||
|
||||
let aFile = aParts.at(-1)
|
||||
let zFile = aParts.at(-1)
|
||||
|
||||
// Sort by depth, shallow first
|
||||
if (aParts.length < zParts.length) return -1
|
||||
if (aParts.length > zParts.length) return 1
|
||||
|
||||
// Sort by filename, sort files named `index` before others
|
||||
if (aFile?.startsWith('index')) return -1
|
||||
if (zFile?.startsWith('index')) return 1
|
||||
|
||||
// Sort by filename, alphabetically
|
||||
return a[0].localeCompare(z[0])
|
||||
})
|
||||
.map(([file, content]) => `--- ${file} ---\n${content || '<EMPTY>'}`)
|
||||
.join('\n\n')}`
|
||||
},
|
||||
async expectFileToContain(filePath, contents) {
|
||||
return retryAssertion(async () => {
|
||||
let fileContent = await this.read(filePath)
|
||||
|
||||
@ -1,16 +1,40 @@
|
||||
import dedent from 'dedent'
|
||||
import postcss from 'postcss'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Stylesheet } from '../stylesheet'
|
||||
import { formatNodes } from './format-nodes'
|
||||
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'
|
||||
|
||||
const css = dedent
|
||||
|
||||
function migrate(input: string) {
|
||||
async function migrate(
|
||||
data:
|
||||
| string
|
||||
| {
|
||||
root: postcss.Root
|
||||
layers?: string[]
|
||||
},
|
||||
) {
|
||||
let stylesheet: Stylesheet
|
||||
|
||||
if (typeof data === 'string') {
|
||||
stylesheet = await Stylesheet.fromString(data)
|
||||
} else {
|
||||
stylesheet = await Stylesheet.fromRoot(data.root)
|
||||
|
||||
if (data.layers) {
|
||||
let meta = { layers: data.layers }
|
||||
let parent = await Stylesheet.fromString('.placeholder {}')
|
||||
|
||||
stylesheet.parents.add({ item: parent, meta })
|
||||
parent.children.add({ item: stylesheet, meta })
|
||||
}
|
||||
}
|
||||
|
||||
return postcss()
|
||||
.use(migrateAtLayerUtilities())
|
||||
.use(migrateAtLayerUtilities(stylesheet))
|
||||
.use(formatNodes())
|
||||
.process(input, { from: expect.getState().testPath })
|
||||
.process(stylesheet.root!, { from: expect.getState().testPath })
|
||||
.then((result) => result.css)
|
||||
}
|
||||
|
||||
@ -820,3 +844,213 @@ it('should not lose attribute selectors', async () => {
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
describe('layered stylesheets', () => {
|
||||
it('should transform classes to utilities inside a layered stylesheet (utilities)', async () => {
|
||||
expect(
|
||||
await migrate({
|
||||
root: postcss.parse(css`
|
||||
/* Utility #1 */
|
||||
.foo {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
`),
|
||||
layers: ['utilities'],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@utility foo {
|
||||
/* Utility #1 */
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
it('should transform classes to utilities inside a layered stylesheet (components)', async () => {
|
||||
expect(
|
||||
await migrate({
|
||||
root: postcss.parse(css`
|
||||
/* Utility #1 */
|
||||
.foo {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
`),
|
||||
layers: ['components'],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@utility foo {
|
||||
/* Utility #1 */
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
it('should NOT transform classes to utilities inside a non-utility, layered stylesheet', async () => {
|
||||
expect(
|
||||
await migrate({
|
||||
root: postcss.parse(css`
|
||||
/* Utility #1 */
|
||||
.foo {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
`),
|
||||
layers: ['foo'],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"/* Utility #1 */
|
||||
.foo {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
it('should handle non-classes in utility-layered stylesheets', async () => {
|
||||
expect(
|
||||
await migrate({
|
||||
root: postcss.parse(css`
|
||||
/* Utility #1 */
|
||||
.foo {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
#main {
|
||||
color: red;
|
||||
}
|
||||
`),
|
||||
layers: ['utilities'],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"
|
||||
#main {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@utility foo {
|
||||
/* Utility #1 */
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
it('should handle non-classes in utility-layered stylesheets', async () => {
|
||||
expect(
|
||||
await migrate({
|
||||
root: postcss.parse(css`
|
||||
@layer utilities {
|
||||
@layer utilities {
|
||||
/* Utility #1 */
|
||||
.foo {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility #2 */
|
||||
.bar {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
|
||||
#main {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility #3 */
|
||||
.baz {
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
|
||||
#secondary {
|
||||
color: red;
|
||||
}
|
||||
`),
|
||||
layers: ['utilities'],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@layer utilities {
|
||||
|
||||
#main {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
#secondary {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@utility foo {
|
||||
@layer utilities {
|
||||
@layer utilities {
|
||||
/* Utility #1 */
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility bar {
|
||||
@layer utilities {
|
||||
/* Utility #2 */
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
@utility baz {
|
||||
/* Utility #3 */
|
||||
/* Declarations: */
|
||||
color: red;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
it('imports are preserved in layered stylesheets', async () => {
|
||||
expect(
|
||||
await migrate({
|
||||
root: postcss.parse(css`
|
||||
@import 'thing';
|
||||
|
||||
.foo {
|
||||
color: red;
|
||||
}
|
||||
`),
|
||||
layers: ['utilities'],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@import 'thing';
|
||||
|
||||
@utility foo {
|
||||
color: red;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
it('charset is preserved in layered stylesheets', async () => {
|
||||
expect(
|
||||
await migrate({
|
||||
root: postcss.parse(css`
|
||||
@charset "utf-8";
|
||||
|
||||
.foo {
|
||||
color: red;
|
||||
}
|
||||
`),
|
||||
layers: ['utilities'],
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@charset "utf-8";
|
||||
|
||||
@utility foo {
|
||||
color: red;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
|
||||
import SelectorParser from 'postcss-selector-parser'
|
||||
import { segment } from '../../../tailwindcss/src/utils/segment'
|
||||
import { Stylesheet } from '../stylesheet'
|
||||
import { walk, WalkAction, walkDepth } from '../utils/walk'
|
||||
|
||||
export function migrateAtLayerUtilities(): Plugin {
|
||||
export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
|
||||
function migrate(atRule: AtRule) {
|
||||
// Only migrate `@layer utilities` and `@layer components`.
|
||||
if (atRule.params !== 'utilities' && atRule.params !== 'components') return
|
||||
@ -86,6 +87,12 @@ export function migrateAtLayerUtilities(): Plugin {
|
||||
clones.push(clone)
|
||||
|
||||
walk(clone, (node) => {
|
||||
if (node.type === 'atrule') {
|
||||
if (!node.nodes || node.nodes?.length === 0) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type !== 'rule') return
|
||||
|
||||
// Fan out each utility into its own rule.
|
||||
@ -186,7 +193,7 @@ export function migrateAtLayerUtilities(): Plugin {
|
||||
|
||||
// Mark the node as pretty so that it gets formatted by Prettier later.
|
||||
clone.raws.tailwind_pretty = true
|
||||
clone.raws.before += '\n\n'
|
||||
clone.raws.before = `${clone.raws.before ?? ''}\n\n`
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
@ -259,7 +266,16 @@ export function migrateAtLayerUtilities(): Plugin {
|
||||
|
||||
return {
|
||||
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
|
||||
OnceExit: (root) => {
|
||||
OnceExit: (root, { atRule }) => {
|
||||
let layers = stylesheet.layers()
|
||||
let isUtilityStylesheet = layers.has('utilities') || layers.has('components')
|
||||
|
||||
if (isUtilityStylesheet) {
|
||||
let rule = atRule({ name: 'layer', params: 'utilities' })
|
||||
rule.append(root.nodes)
|
||||
root.append(rule)
|
||||
}
|
||||
|
||||
// Migrate `@layer utilities` and `@layer components` into `@utility`.
|
||||
// Using this instead of the visitor API in case we want to use
|
||||
// postcss-nesting in the future.
|
||||
@ -282,6 +298,17 @@ export function migrateAtLayerUtilities(): Plugin {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If the stylesheet is inside a layered import then we can remove the top-level layer directive we added
|
||||
if (isUtilityStylesheet) {
|
||||
root.each((node) => {
|
||||
if (node.type !== 'atrule') return
|
||||
if (node.name !== 'layer') return
|
||||
if (node.params !== 'utilities') return
|
||||
|
||||
node.replaceWith(node.nodes ?? [])
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@ export function migrateMissingLayers(): Plugin {
|
||||
if (node.name === 'import') {
|
||||
if (lastLayer !== '' && !node.params.includes('layer(')) {
|
||||
node.params += ` layer(${lastLayer})`
|
||||
node.raws.tailwind_injected_layer = true
|
||||
}
|
||||
|
||||
if (bucket.length > 0) {
|
||||
@ -110,7 +111,7 @@ export function migrateMissingLayers(): Plugin {
|
||||
let target = nodes[0]
|
||||
let layerNode = new AtRule({
|
||||
name: 'layer',
|
||||
params: layerName || firstLayerName || '',
|
||||
params: targetLayerName,
|
||||
nodes: nodes.map((node) => {
|
||||
// Keep the target node as-is, because we will be replacing that one
|
||||
// with the new layer node.
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import dedent from 'dedent'
|
||||
import postcss from 'postcss'
|
||||
import { expect, it } from 'vitest'
|
||||
import { formatNodes } from './codemods/format-nodes'
|
||||
import { migrateContents } from './migrate'
|
||||
|
||||
const css = dedent
|
||||
@ -13,9 +15,15 @@ let designSystem = await __unstable__loadDesignSystem(
|
||||
)
|
||||
let config = { designSystem, userConfig: {}, newPrefix: null }
|
||||
|
||||
function migrate(input: string, config: any) {
|
||||
return migrateContents(input, config, expect.getState().testPath)
|
||||
.then((result) => postcss([formatNodes()]).process(result.root, result.opts))
|
||||
.then((result) => result.css)
|
||||
}
|
||||
|
||||
it('should print the input as-is', async () => {
|
||||
expect(
|
||||
await migrateContents(
|
||||
await migrate(
|
||||
css`
|
||||
/* above */
|
||||
.foo/* after */ {
|
||||
@ -25,7 +33,6 @@ it('should print the input as-is', async () => {
|
||||
}
|
||||
`,
|
||||
config,
|
||||
expect.getState().testPath,
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
"/* above */
|
||||
@ -39,7 +46,7 @@ it('should print the input as-is', async () => {
|
||||
|
||||
it('should migrate a stylesheet', async () => {
|
||||
expect(
|
||||
await migrateContents(
|
||||
await migrate(
|
||||
css`
|
||||
@tailwind base;
|
||||
|
||||
@ -116,7 +123,7 @@ it('should migrate a stylesheet', async () => {
|
||||
|
||||
it('should migrate a stylesheet (with imports)', async () => {
|
||||
expect(
|
||||
await migrateContents(
|
||||
await migrate(
|
||||
css`
|
||||
@import 'tailwindcss/base';
|
||||
@import './my-base.css';
|
||||
@ -137,7 +144,7 @@ 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(
|
||||
await migrate(
|
||||
css`
|
||||
@charset "UTF-8";
|
||||
@layer foo, bar, baz;
|
||||
@ -166,7 +173,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in
|
||||
|
||||
it('should keep CSS as-is before existing `@layer` at-rules', async () => {
|
||||
expect(
|
||||
await migrateContents(
|
||||
await migrate(
|
||||
css`
|
||||
.foo {
|
||||
color: blue;
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { globby } from 'globby'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import postcss from 'postcss'
|
||||
import { formatNodes } from './codemods/format-nodes'
|
||||
import { help } from './commands/help'
|
||||
import { migrate as migrateStylesheet } from './migrate'
|
||||
import {
|
||||
analyze as analyzeStylesheets,
|
||||
migrate as migrateStylesheet,
|
||||
split as splitStylesheets,
|
||||
} from './migrate'
|
||||
import { migratePostCSSConfig } from './migrate-postcss'
|
||||
import { Stylesheet } from './stylesheet'
|
||||
import { migrate as migrateTemplate } from './template/migrate'
|
||||
import { prepareConfig } from './template/prepare-config'
|
||||
import { args, type Arg } from './utils/args'
|
||||
@ -94,8 +102,56 @@ async function run() {
|
||||
// Ensure we are only dealing with CSS files
|
||||
files = files.filter((file) => file.endsWith('.css'))
|
||||
|
||||
// Analyze the stylesheets
|
||||
let loadResults = await Promise.allSettled(files.map((filepath) => Stylesheet.load(filepath)))
|
||||
|
||||
// Load and parse all stylesheets
|
||||
for (let result of loadResults) {
|
||||
if (result.status === 'rejected') {
|
||||
error(`${result.reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
let stylesheets = loadResults
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
|
||||
// Analyze the stylesheets
|
||||
try {
|
||||
await analyzeStylesheets(stylesheets)
|
||||
} catch (e: unknown) {
|
||||
error(`${e}`)
|
||||
}
|
||||
|
||||
// Migrate each file
|
||||
await Promise.allSettled(files.map((file) => migrateStylesheet(file, config)))
|
||||
let migrateResults = await Promise.allSettled(
|
||||
stylesheets.map((sheet) => migrateStylesheet(sheet, config)),
|
||||
)
|
||||
|
||||
for (let result of migrateResults) {
|
||||
if (result.status === 'rejected') {
|
||||
error(`${result.reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Split up stylesheets (as needed)
|
||||
try {
|
||||
await splitStylesheets(stylesheets)
|
||||
} catch (e: unknown) {
|
||||
error(`${e}`)
|
||||
}
|
||||
|
||||
// Format nodes
|
||||
for (let sheet of stylesheets) {
|
||||
await postcss([formatNodes()]).process(sheet.root!, { from: sheet.file! })
|
||||
}
|
||||
|
||||
// Write all files to disk
|
||||
for (let sheet of stylesheets) {
|
||||
if (!sheet.file) continue
|
||||
|
||||
await fs.writeFile(sheet.file, sheet.root.toString())
|
||||
}
|
||||
|
||||
success('Stylesheet migration complete.')
|
||||
}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import postcss from 'postcss'
|
||||
import type { Config } from 'tailwindcss'
|
||||
import type { DesignSystem } from '../../tailwindcss/src/design-system'
|
||||
import { formatNodes } from './codemods/format-nodes'
|
||||
import { DefaultMap } from '../../tailwindcss/src/utils/default-map'
|
||||
import { segment } from '../../tailwindcss/src/utils/segment'
|
||||
import { migrateAtApply } from './codemods/migrate-at-apply'
|
||||
import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
|
||||
import { migrateMediaScreen } from './codemods/migrate-media-screen'
|
||||
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
|
||||
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
|
||||
import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet'
|
||||
import { resolveCssId } from './utils/resolve'
|
||||
import { walk, WalkAction } from './utils/walk'
|
||||
|
||||
export interface MigrateOptions {
|
||||
newPrefix: string | null
|
||||
@ -16,21 +19,391 @@ export interface MigrateOptions {
|
||||
userConfig: Config
|
||||
}
|
||||
|
||||
export async function migrateContents(contents: string, options: MigrateOptions, file?: string) {
|
||||
export async function migrateContents(
|
||||
stylesheet: Stylesheet | string,
|
||||
options: MigrateOptions,
|
||||
file?: string,
|
||||
) {
|
||||
if (typeof stylesheet === 'string') {
|
||||
stylesheet = await Stylesheet.fromString(stylesheet)
|
||||
stylesheet.file = file ?? null
|
||||
}
|
||||
|
||||
return postcss()
|
||||
.use(migrateAtApply(options))
|
||||
.use(migrateMediaScreen(options))
|
||||
.use(migrateAtLayerUtilities())
|
||||
.use(migrateAtLayerUtilities(stylesheet))
|
||||
.use(migrateMissingLayers())
|
||||
.use(migrateTailwindDirectives(options))
|
||||
.use(formatNodes())
|
||||
.process(contents, { from: file })
|
||||
.then((result) => result.css)
|
||||
.process(stylesheet.root, { from: stylesheet.file ?? undefined })
|
||||
}
|
||||
|
||||
export async function migrate(file: string, options: MigrateOptions) {
|
||||
let fullPath = path.resolve(process.cwd(), file)
|
||||
let contents = await fs.readFile(fullPath, 'utf-8')
|
||||
export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) {
|
||||
if (!stylesheet.file) {
|
||||
throw new Error('Cannot migrate a stylesheet without a file path')
|
||||
}
|
||||
|
||||
await fs.writeFile(fullPath, await migrateContents(contents, options, fullPath))
|
||||
if (!stylesheet.canMigrate) return
|
||||
|
||||
await migrateContents(stylesheet, options)
|
||||
}
|
||||
|
||||
export async function analyze(stylesheets: Stylesheet[]) {
|
||||
let stylesheetsByFile = new Map<string, Stylesheet>()
|
||||
|
||||
for (let sheet of stylesheets) {
|
||||
if (sheet.file) {
|
||||
stylesheetsByFile.set(sheet.file, sheet)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Record which `@import` rules point to which stylesheets
|
||||
// and which stylesheets are parents/children of each other
|
||||
let processor = postcss([
|
||||
{
|
||||
postcssPlugin: 'mark-import-nodes',
|
||||
AtRule: {
|
||||
import(node) {
|
||||
// Find what the import points to
|
||||
let id = node.params.match(/['"](.*)['"]/)?.[1]
|
||||
if (!id) return
|
||||
|
||||
let basePath = node.source?.input.file
|
||||
? path.dirname(node.source.input.file)
|
||||
: process.cwd()
|
||||
|
||||
// Resolve the import to a file path
|
||||
let resolvedPath: string | false
|
||||
try {
|
||||
resolvedPath = resolveCssId(id, basePath)
|
||||
} catch (err) {
|
||||
console.warn(`Failed to resolve import: ${id}. Skipping.`)
|
||||
console.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if (!resolvedPath) return
|
||||
|
||||
// Find the stylesheet pointing to the resolved path
|
||||
let stylesheet = stylesheetsByFile.get(resolvedPath)
|
||||
|
||||
// If it _does not_ exist in stylesheets we don't care and skip it
|
||||
// this is likely because its in node_modules or a workspace package
|
||||
// that we don't want to modify
|
||||
if (!stylesheet) return
|
||||
|
||||
// Mark the import node with the ID of the stylesheet it points to
|
||||
// We will use these later to build lookup tables and modify the AST
|
||||
node.raws.tailwind_destination_sheet_id = stylesheet.id
|
||||
|
||||
let parent = node.source?.input.file
|
||||
? stylesheetsByFile.get(node.source.input.file)
|
||||
: undefined
|
||||
|
||||
let layers: string[] = []
|
||||
|
||||
for (let part of segment(node.params, ' ')) {
|
||||
if (!part.startsWith('layer(')) continue
|
||||
if (!part.endsWith(')')) continue
|
||||
|
||||
layers.push(part.slice(6, -1).trim())
|
||||
}
|
||||
|
||||
// Connect sheets together in a dependency graph
|
||||
if (parent) {
|
||||
let meta = { layers }
|
||||
stylesheet.parents.add({ item: parent, meta })
|
||||
parent.children.add({ item: stylesheet, meta })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
for (let sheet of stylesheets) {
|
||||
if (!sheet.file) continue
|
||||
|
||||
await processor.process(sheet.root, { from: sheet.file })
|
||||
}
|
||||
|
||||
let commonPath = process.cwd()
|
||||
|
||||
function pathToString(path: StylesheetConnection[]) {
|
||||
let parts: string[] = []
|
||||
|
||||
for (let connection of path) {
|
||||
if (!connection.item.file) continue
|
||||
|
||||
let filePath = connection.item.file.replace(commonPath, '')
|
||||
let layers = connection.meta.layers.join(', ')
|
||||
|
||||
if (layers.length > 0) {
|
||||
parts.push(`${filePath} (layers: ${layers})`)
|
||||
} else {
|
||||
parts.push(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' <- ')
|
||||
}
|
||||
|
||||
let lines: string[] = []
|
||||
|
||||
for (let sheet of stylesheets) {
|
||||
if (!sheet.file) continue
|
||||
|
||||
let { convertablePaths, nonConvertablePaths } = sheet.analyzeImportPaths()
|
||||
let isAmbiguous = convertablePaths.length > 0 && nonConvertablePaths.length > 0
|
||||
|
||||
if (!isAmbiguous) continue
|
||||
|
||||
sheet.canMigrate = false
|
||||
|
||||
let filePath = sheet.file.replace(commonPath, '')
|
||||
|
||||
for (let path of convertablePaths) {
|
||||
lines.push(`- ${filePath} <- ${pathToString(path)}`)
|
||||
}
|
||||
|
||||
for (let path of nonConvertablePaths) {
|
||||
lines.push(`- ${filePath} <- ${pathToString(path)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) return
|
||||
|
||||
let error = `You have one or more stylesheets that are imported into a utility layer and non-utility layer.\n`
|
||||
error += `We cannot convert stylesheets under these conditions. Please look at the following stylesheets:\n`
|
||||
|
||||
throw new Error(error + lines.join('\n'))
|
||||
}
|
||||
|
||||
export async function split(stylesheets: Stylesheet[]) {
|
||||
let stylesheetsById = new Map<StylesheetId, Stylesheet>()
|
||||
let stylesheetsByFile = new Map<string, Stylesheet>()
|
||||
|
||||
for (let sheet of stylesheets) {
|
||||
stylesheetsById.set(sheet.id, sheet)
|
||||
|
||||
if (sheet.file) {
|
||||
stylesheetsByFile.set(sheet.file, sheet)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep track of sheets that contain `@utillity` rules
|
||||
let containsUtilities = new Set<Stylesheet>()
|
||||
|
||||
for (let sheet of stylesheets) {
|
||||
let layers = sheet.layers()
|
||||
let isLayered = layers.has('utilities') || layers.has('components')
|
||||
if (!isLayered) continue
|
||||
|
||||
walk(sheet.root, (node) => {
|
||||
if (node.type !== 'atrule') return
|
||||
if (node.name !== 'utility') return
|
||||
|
||||
containsUtilities.add(sheet)
|
||||
|
||||
return WalkAction.Stop
|
||||
})
|
||||
}
|
||||
|
||||
// Split every imported stylesheet into two parts
|
||||
let utilitySheets = new Map<Stylesheet, Stylesheet>()
|
||||
|
||||
for (let sheet of stylesheets) {
|
||||
// Ignore stylesheets that were not imported
|
||||
if (!sheet.file) continue
|
||||
if (sheet.parents.size === 0) continue
|
||||
|
||||
// Skip stylesheets that don't have utilities
|
||||
// and don't have any children that have utilities
|
||||
if (!containsUtilities.has(sheet)) {
|
||||
if (!Array.from(sheet.descendants()).some((child) => containsUtilities.has(child))) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let utilities = postcss.root({
|
||||
raws: {
|
||||
tailwind_pretty: true,
|
||||
},
|
||||
})
|
||||
|
||||
walk(sheet.root, (node) => {
|
||||
if (node.type !== 'atrule') return
|
||||
if (node.name !== 'utility') return
|
||||
|
||||
// `append` will move this node from the original sheet
|
||||
// to the new utilities sheet
|
||||
utilities.append(node)
|
||||
|
||||
return WalkAction.Skip
|
||||
})
|
||||
|
||||
let newFileName = sheet.file.replace(/\.css$/, '.utilities.css')
|
||||
|
||||
let counter = 0
|
||||
|
||||
// If we already have a utility sheet with this name, we need to rename it
|
||||
while (stylesheetsByFile.has(newFileName)) {
|
||||
counter += 1
|
||||
newFileName = sheet.file.replace(/\.css$/, `.utilities.${counter}.css`)
|
||||
}
|
||||
|
||||
let utilitySheet = await Stylesheet.fromRoot(utilities, newFileName)
|
||||
|
||||
utilitySheet.extension = counter > 0 ? `.utilities.${counter}.css` : `.utilities.css`
|
||||
|
||||
utilitySheets.set(sheet, utilitySheet)
|
||||
stylesheetsById.set(utilitySheet.id, utilitySheet)
|
||||
}
|
||||
|
||||
// Make sure the utility sheets are linked to one another
|
||||
for (let [normalSheet, utilitySheet] of utilitySheets) {
|
||||
for (let parent of normalSheet.parents) {
|
||||
let utilityParent = utilitySheets.get(parent.item)
|
||||
if (!utilityParent) continue
|
||||
utilitySheet.parents.add({
|
||||
item: utilityParent,
|
||||
meta: parent.meta,
|
||||
})
|
||||
}
|
||||
|
||||
for (let child of normalSheet.children) {
|
||||
let utilityChild = utilitySheets.get(child.item)
|
||||
if (!utilityChild) continue
|
||||
utilitySheet.children.add({
|
||||
item: utilityChild,
|
||||
meta: child.meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (let sheet of stylesheets) {
|
||||
let utilitySheet = utilitySheets.get(sheet)
|
||||
let utilityImports: Set<postcss.AtRule> = new Set()
|
||||
|
||||
for (let node of sheet.importRules) {
|
||||
let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined
|
||||
|
||||
// This import rule does not point to a stylesheet
|
||||
// which likely means it points to `node_modules`
|
||||
if (!sheetId) continue
|
||||
|
||||
let originalDestination = stylesheetsById.get(sheetId)
|
||||
|
||||
// This import points to a stylesheet that no longer exists which likely
|
||||
// means it was removed by the optimizer this will be cleaned up later
|
||||
if (!originalDestination) continue
|
||||
|
||||
let utilityDestination = utilitySheets.get(originalDestination)
|
||||
|
||||
// A utility sheet doesn't exist for this import so it doesn't need
|
||||
// to be processed
|
||||
if (!utilityDestination) continue
|
||||
|
||||
let match = node.params.match(/(['"])(.*)\1/)
|
||||
if (!match) return
|
||||
|
||||
let quote = match[1]
|
||||
let id = match[2]
|
||||
|
||||
let newFile = id.replace(/\.css$/, utilityDestination.extension!)
|
||||
|
||||
// The import will just point to the new file without any media queries,
|
||||
// layers, or other conditions because `@utility` MUST be top-level.
|
||||
let newImport = node.clone({
|
||||
params: `${quote}${newFile}${quote}`,
|
||||
raws: {
|
||||
after: '\n\n',
|
||||
tailwind_original_params: `${quote}${id}${quote}`,
|
||||
tailwind_destination_sheet_id: utilityDestination.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (utilitySheet) {
|
||||
// If this import is intended to go into the utility sheet
|
||||
// we'll collect it into a list to add later. If we don't'
|
||||
// we'll end up adding them in reverse order.
|
||||
utilityImports.add(newImport)
|
||||
} else {
|
||||
// This import will go immediately after the original import
|
||||
node.after(newImport)
|
||||
}
|
||||
}
|
||||
|
||||
// Add imports to the top of the utility sheet if necessary
|
||||
if (utilitySheet && utilityImports.size > 0) {
|
||||
utilitySheet.root.prepend(Array.from(utilityImports))
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks the at rules that import a given stylesheet
|
||||
let importNodes = new DefaultMap<Stylesheet, Set<postcss.AtRule>>(() => new Set())
|
||||
|
||||
for (let sheet of stylesheetsById.values()) {
|
||||
for (let node of sheet.importRules) {
|
||||
let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined
|
||||
|
||||
// This import rule does not point to a stylesheet
|
||||
if (!sheetId) continue
|
||||
|
||||
let destination = stylesheetsById.get(sheetId)
|
||||
|
||||
// This import rule does not point to a stylesheet that exists
|
||||
// We'll remove it later
|
||||
if (!destination) continue
|
||||
|
||||
importNodes.get(destination).add(node)
|
||||
}
|
||||
}
|
||||
|
||||
// At this point we've created many `{name}.utilities.css` files.
|
||||
// If the original file _becomes_ empty after splitting that means that
|
||||
// dedicated utility file is not required and we can move the utilities
|
||||
// back to the original file.
|
||||
//
|
||||
// This could be done in one step but separating them makes it easier to
|
||||
// reason about since the stylesheets are in a consistent state before we
|
||||
// perform any cleanup tasks.
|
||||
let list: Stylesheet[] = []
|
||||
|
||||
for (let sheet of stylesheets.slice()) {
|
||||
for (let child of sheet.descendants()) {
|
||||
list.push(child)
|
||||
}
|
||||
|
||||
list.push(sheet)
|
||||
}
|
||||
|
||||
for (let sheet of list) {
|
||||
let utilitySheet = utilitySheets.get(sheet)
|
||||
|
||||
// This sheet was not split so there's nothing to do
|
||||
if (!utilitySheet) continue
|
||||
|
||||
// This sheet did not become empty
|
||||
if (!sheet.isEmpty) continue
|
||||
|
||||
// We have a sheet that became empty after splitting
|
||||
// 1. Replace the sheet with it's utility sheet content
|
||||
sheet.root = utilitySheet.root
|
||||
|
||||
// 2. Rewrite imports in parent sheets to point to the original sheet
|
||||
// Ideally this wouldn't need to be _undone_ but instead only done once at the end
|
||||
for (let node of importNodes.get(utilitySheet)) {
|
||||
node.params = node.raws.tailwind_original_params as any
|
||||
}
|
||||
|
||||
// 3. Remove the original import from the non-utility sheet
|
||||
for (let node of importNodes.get(sheet)) {
|
||||
node.remove()
|
||||
}
|
||||
|
||||
// 3. Mark the utility sheet for removal
|
||||
utilitySheets.delete(sheet)
|
||||
}
|
||||
|
||||
stylesheets.push(...utilitySheets.values())
|
||||
}
|
||||
|
||||
249
packages/@tailwindcss-upgrade/src/stylesheet.ts
Normal file
249
packages/@tailwindcss-upgrade/src/stylesheet.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as util from 'node:util'
|
||||
import * as postcss from 'postcss'
|
||||
|
||||
export type StylesheetId = string
|
||||
|
||||
export interface StylesheetConnection {
|
||||
item: Stylesheet
|
||||
meta: {
|
||||
layers: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export class Stylesheet {
|
||||
/**
|
||||
* A unique identifier for this stylesheet
|
||||
*
|
||||
* Used to track the stylesheet in PostCSS nodes.
|
||||
*/
|
||||
id: StylesheetId
|
||||
|
||||
/**
|
||||
* The PostCSS AST that represents this stylesheet.
|
||||
*/
|
||||
root: postcss.Root
|
||||
|
||||
/**
|
||||
* The path to the file that this stylesheet was loaded from.
|
||||
*
|
||||
* If this stylesheet was not loaded from a file this will be `null`.
|
||||
*/
|
||||
file: string | null = null
|
||||
|
||||
/**
|
||||
* Stylesheets that import this stylesheet.
|
||||
*/
|
||||
parents = new Set<StylesheetConnection>()
|
||||
|
||||
/**
|
||||
* Stylesheets that are imported by stylesheet.
|
||||
*/
|
||||
children = new Set<StylesheetConnection>()
|
||||
|
||||
/**
|
||||
* Whether or not this stylesheet can be migrated
|
||||
*/
|
||||
canMigrate = true
|
||||
|
||||
/**
|
||||
* Whether or not this stylesheet can be migrated
|
||||
*/
|
||||
extension: string | null = null
|
||||
|
||||
static async load(filepath: string) {
|
||||
filepath = path.resolve(process.cwd(), filepath)
|
||||
|
||||
let css = await fs.readFile(filepath, 'utf-8')
|
||||
let root = postcss.parse(css, { from: filepath })
|
||||
|
||||
return new Stylesheet(root, filepath)
|
||||
}
|
||||
|
||||
static async fromString(css: string) {
|
||||
let root = postcss.parse(css)
|
||||
|
||||
return new Stylesheet(root)
|
||||
}
|
||||
|
||||
static async fromRoot(root: postcss.Root, file?: string) {
|
||||
return new Stylesheet(root, file)
|
||||
}
|
||||
|
||||
constructor(root: postcss.Root, file?: string) {
|
||||
this.id = Math.random().toString(36).slice(2)
|
||||
this.root = root
|
||||
this.file = file ?? null
|
||||
|
||||
if (file) {
|
||||
this.extension = path.extname(file)
|
||||
}
|
||||
}
|
||||
|
||||
get importRules() {
|
||||
let imports = new Set<postcss.AtRule>()
|
||||
|
||||
this.root.walkAtRules('import', (rule) => {
|
||||
imports.add(rule)
|
||||
})
|
||||
|
||||
return imports
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.root.toString().trim() === ''
|
||||
}
|
||||
|
||||
*ancestors() {
|
||||
for (let { item } of walkDepth(this, (sheet) => sheet.parents)) {
|
||||
yield item
|
||||
}
|
||||
}
|
||||
|
||||
*descendants() {
|
||||
for (let { item } of walkDepth(this, (sheet) => sheet.children)) {
|
||||
yield item
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the layers the stylesheet is imported into directly or indirectly
|
||||
*/
|
||||
layers() {
|
||||
let layers = new Set<string>()
|
||||
|
||||
for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) {
|
||||
if (item.parents.size > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (let { meta } of path) {
|
||||
for (let layer of meta.layers) {
|
||||
layers.add(layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layers
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate all paths from a stylesheet through its ancestors to all roots
|
||||
*
|
||||
* For example, given the following structure:
|
||||
*
|
||||
* ```
|
||||
* c.css
|
||||
* -> a.1.css @import "…"
|
||||
* -> a.css
|
||||
* -> root.1.css (utility: no)
|
||||
* -> root.2.css (utility: no)
|
||||
* -> b.css
|
||||
* -> root.1.css (utility: no)
|
||||
* -> root.2.css (utility: no)
|
||||
*
|
||||
* -> a.2.css @import "…" layer(foo)
|
||||
* -> a.css
|
||||
* -> root.1.css (utility: no)
|
||||
* -> root.2.css (utility: no)
|
||||
* -> b.css
|
||||
* -> root.1.css (utility: no)
|
||||
* -> root.2.css (utility: no)
|
||||
*
|
||||
* -> b.1.css @import "…" layer(components / utilities)
|
||||
* -> a.css
|
||||
* -> root.1.css (utility: yes)
|
||||
* -> root.2.css (utility: yes)
|
||||
* -> b.css
|
||||
* -> root.1.css (utility: yes)
|
||||
* -> root.2.css (utility: yes)
|
||||
* ```
|
||||
*
|
||||
* We can see there are a total of 12 import paths with various layers.
|
||||
* We need to be able to iterate every one of these paths and inspect
|
||||
* the layers used in each path..
|
||||
*/
|
||||
*pathsToRoot(): Iterable<StylesheetConnection[]> {
|
||||
for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) {
|
||||
// Skip over intermediate stylesheets since all paths from a leaf to a
|
||||
// root will encompass all possible intermediate stylesheet paths.
|
||||
if (item.parents.size > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
yield path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a stylesheets import paths to see if some can be considered
|
||||
* for conversion to utility rules and others can't.
|
||||
*
|
||||
* If a stylesheet is imported directly or indirectly and some imports are in
|
||||
* a utility layer and some are not that means that we can't safely convert
|
||||
* the rules in the stylesheet to `@utility`. Doing so would mean that we
|
||||
* would need to replicate the stylesheet and change one to have `@utility`
|
||||
* rules and leave the other as is.
|
||||
*
|
||||
* We can see, given the same structure from the `pathsToRoot` example, that
|
||||
* `css.css` is imported into different layers:
|
||||
* - `a.1.css` has no layers and should not be converted
|
||||
* - `a.2.css` has a layer `foo` and should not be converted
|
||||
* - `b.1.css` has a layer `utilities` (or `components`) which should be
|
||||
*
|
||||
* Since this means that `c.css` must both not be converted and converted
|
||||
* we can't do this without replicating the stylesheet, any ancestors, and
|
||||
* adjusting imports which is a non-trivial task.
|
||||
*/
|
||||
analyzeImportPaths() {
|
||||
let convertablePaths: StylesheetConnection[][] = []
|
||||
let nonConvertablePaths: StylesheetConnection[][] = []
|
||||
|
||||
for (let path of this.pathsToRoot()) {
|
||||
let isConvertable = false
|
||||
|
||||
for (let { meta } of path) {
|
||||
for (let layer of meta.layers) {
|
||||
isConvertable ||= layer === 'utilities' || layer === 'components'
|
||||
}
|
||||
}
|
||||
|
||||
if (isConvertable) {
|
||||
convertablePaths.push(path)
|
||||
} else {
|
||||
nonConvertablePaths.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
return { convertablePaths, nonConvertablePaths }
|
||||
}
|
||||
|
||||
[util.inspect.custom]() {
|
||||
return {
|
||||
...this,
|
||||
root: this.root.toString(),
|
||||
layers: Array.from(this.layers()),
|
||||
parents: Array.from(this.parents, (s) => s.item.id),
|
||||
children: Array.from(this.children, (s) => s.item.id),
|
||||
parentsMeta: Array.from(this.parents, (s) => s.meta),
|
||||
childrenMeta: Array.from(this.children, (s) => s.meta),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* walkDepth(
|
||||
value: Stylesheet,
|
||||
connections: (value: Stylesheet) => Iterable<StylesheetConnection>,
|
||||
path: StylesheetConnection[] = [],
|
||||
): Iterable<{ item: Stylesheet; path: StylesheetConnection[] }> {
|
||||
for (let connection of connections(value)) {
|
||||
let newPath = [...path, connection]
|
||||
|
||||
yield* walkDepth(connection.item, connections, newPath)
|
||||
yield {
|
||||
item: connection.item,
|
||||
path: newPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user