Add CSS codemod for missing @layer (#14504)
This PR adds a codemod that ensures that some parts of your stylesheet are wrapped in an `@layer`. This is a follow-up PR of #14411, in that PR we migrate `@tailwind` directives to imports. As a quick summary, that will turn this: ```css @tailwind base; @tailwind components; @tailwind utilities; ``` Into: ```css @import 'tailwindcss'; ``` But there are a few issues with that _if_ we have additional CSS on the page. For example let's imagine we had this: ```css @tailwind base; body { background-color: red; } @tailwind components; .btn {} @tailwind utilities; ``` This will now be turned into: ```css @import 'tailwindcss'; body { background-color: red; } .btn {} ``` But in v4 we use real layers, in v3 we used to replace the directive with the result of that layer. This means that now the `body` and `.btn` styles are in the incorrect spot. To solve this, we have to wrap them in a layer. The `body` should go in an `@layer base`, and the `.btn` should be in an `@layer components` to make sure it's in the same spot as it was before. That's what this PR does, the original input will now be turned into: ```css @import 'tailwindcss'; @layer base { body { background-color: red; } } @layer components { .btn { } } ``` There are a few internal refactors going on as well, but those are less important.
This commit is contained in:
parent
d14249ddc2
commit
d869442a54
14 changed files with 423 additions and 80 deletions
|
|
@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Add new `shadow-initial` and `inset-shadow-initial` utilities for resetting shadow colors ([#14468](https://github.com/tailwindlabs/tailwindcss/pull/14468))
|
||||
- Add `field-sizing-*` utilities ([#14469](https://github.com/tailwindlabs/tailwindcss/pull/14469))
|
||||
- Include gradient color properties in color transitions ([#14489](https://github.com/tailwindlabs/tailwindcss/pull/14489))
|
||||
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411))
|
||||
- _Experimental_: Add CSS codemods for `@apply` ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14434))
|
||||
- _Experimental_: Add CSS codemods for migrating `@tailwind` directives ([#14411](https://github.com/tailwindlabs/tailwindcss/pull/14411), [#14504](https://github.com/tailwindlabs/tailwindcss/pull/14504))
|
||||
- _Experimental_: Add CSS codemods for migrating `@layer utilities` and `@layer components` ([#14455](https://github.com/tailwindlabs/tailwindcss/pull/14455))
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -65,7 +65,17 @@ test(
|
|||
`,
|
||||
'src/index.css': css`
|
||||
@tailwind base;
|
||||
|
||||
html {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
|
||||
.btn {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@tailwind utilities;
|
||||
`,
|
||||
},
|
||||
|
|
@ -73,7 +83,27 @@ 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`@import 'tailwindcss';`)
|
||||
await fs.expectFileToContain(
|
||||
'src/index.css',
|
||||
css`
|
||||
@layer base {
|
||||
html {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
`,
|
||||
)
|
||||
await fs.expectFileToContain(
|
||||
'src/index.css',
|
||||
css`
|
||||
@layer components {
|
||||
.btn {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import postcss, { type Plugin } from 'postcss'
|
||||
import { expect, it } from 'vitest'
|
||||
import { formatNodes } from './format-nodes'
|
||||
|
||||
function markPretty(): Plugin {
|
||||
return {
|
||||
postcssPlugin: '@tailwindcss/upgrade/mark-pretty',
|
||||
OnceExit(root) {
|
||||
root.walkAtRules('utility', (atRule) => {
|
||||
atRule.raws.tailwind_pretty = true
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(input: string) {
|
||||
return postcss()
|
||||
.use(markPretty())
|
||||
.use(formatNodes())
|
||||
.process(input, { from: expect.getState().testPath })
|
||||
.then((result) => result.css)
|
||||
}
|
||||
|
||||
it('should format PostCSS nodes that are marked with tailwind_pretty', async () => {
|
||||
expect(
|
||||
await migrate(`
|
||||
@utility .foo { .foo { color: red; } }`),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@utility .foo {
|
||||
.foo {
|
||||
color: red;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
30
packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts
Normal file
30
packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { parse, type ChildNode, type Plugin, type Root } from 'postcss'
|
||||
import { format } from 'prettier'
|
||||
import { walk, WalkAction } from '../utils/walk'
|
||||
|
||||
// Prettier is used to generate cleaner output, but it's only used on the nodes
|
||||
// that were marked as `pretty` during the migration.
|
||||
export function formatNodes(): Plugin {
|
||||
async function migrate(root: Root) {
|
||||
// Find the nodes to format
|
||||
let nodesToFormat: ChildNode[] = []
|
||||
walk(root, (child) => {
|
||||
if (child.raws.tailwind_pretty) {
|
||||
nodesToFormat.push(child)
|
||||
return WalkAction.Skip
|
||||
}
|
||||
})
|
||||
|
||||
// Format the nodes
|
||||
await Promise.all(
|
||||
nodesToFormat.map(async (node) => {
|
||||
node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true })))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
postcssPlugin: '@tailwindcss/upgrade/format-nodes',
|
||||
OnceExit: migrate,
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ export function migrateAtApply(): Plugin {
|
|||
let params = utilities.map((part) => {
|
||||
// Keep whitespace
|
||||
if (part.trim() === '') return part
|
||||
|
||||
let variants = segment(part, ':')
|
||||
let utility = variants.pop()!
|
||||
|
||||
|
|
@ -36,8 +35,8 @@ export function migrateAtApply(): Plugin {
|
|||
|
||||
return {
|
||||
postcssPlugin: '@tailwindcss/upgrade/migrate-at-apply',
|
||||
AtRule: {
|
||||
apply: migrate,
|
||||
OnceExit(root) {
|
||||
root.walkAtRules('apply', migrate)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import dedent from 'dedent'
|
||||
import postcss from 'postcss'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatNodes } from './format-nodes'
|
||||
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'
|
||||
|
||||
const css = dedent
|
||||
|
|
@ -8,6 +9,7 @@ const css = dedent
|
|||
function migrate(input: string) {
|
||||
return postcss()
|
||||
.use(migrateAtLayerUtilities())
|
||||
.use(formatNodes())
|
||||
.process(input, { from: expect.getState().testPath })
|
||||
.then((result) => result.css)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,16 @@
|
|||
import { AtRule, parse, Rule, type ChildNode, type Comment, type Plugin } from 'postcss'
|
||||
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
|
||||
import SelectorParser from 'postcss-selector-parser'
|
||||
import { format } from 'prettier'
|
||||
import { segment } from '../../../tailwindcss/src/utils/segment'
|
||||
|
||||
enum WalkAction {
|
||||
// Continue walking the tree. Default behavior.
|
||||
Continue,
|
||||
|
||||
// Skip walking into the current node.
|
||||
Skip,
|
||||
|
||||
// Stop walking the tree entirely.
|
||||
Stop,
|
||||
}
|
||||
|
||||
interface Walkable<T> {
|
||||
each(cb: (node: T, index: number) => void): void
|
||||
}
|
||||
|
||||
// Custom walk implementation where we can skip going into nodes when we don't
|
||||
// need to process them.
|
||||
function walk<T>(rule: Walkable<T>, cb: (rule: T) => void | WalkAction): undefined | false {
|
||||
let result: undefined | false = undefined
|
||||
|
||||
rule.each?.((node) => {
|
||||
let action = cb(node) ?? WalkAction.Continue
|
||||
if (action === WalkAction.Stop) {
|
||||
result = false
|
||||
return result
|
||||
}
|
||||
if (action !== WalkAction.Skip) {
|
||||
result = walk(node as Walkable<T>, cb)
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Depth first walk reversal implementation.
|
||||
function walkDepth<T>(rule: Walkable<T>, cb: (rule: T) => void) {
|
||||
rule?.each?.((node) => {
|
||||
walkDepth(node as Walkable<T>, cb)
|
||||
cb(node)
|
||||
})
|
||||
}
|
||||
import { walk, WalkAction, walkDepth } from '../utils/walk'
|
||||
|
||||
export function migrateAtLayerUtilities(): Plugin {
|
||||
function migrate(atRule: AtRule) {
|
||||
// Only migrate `@layer utilities` and `@layer components`.
|
||||
if (atRule.params !== 'utilities' && atRule.params !== 'components') return
|
||||
|
||||
// If the `@layer utilities` contains CSS that should not be turned into an
|
||||
// `@utility` at-rule, then we have to keep it around (including the
|
||||
// `@layer utilities` wrapper). To prevent this from being processed over
|
||||
// and over again, we mark it as seen and bail early.
|
||||
if (atRule.raws.seen) return
|
||||
|
||||
// Keep rules that should not be turned into utilities as is. This will
|
||||
// include rules with element or ID selectors.
|
||||
let defaultsAtRule = atRule.clone({ raws: { seen: true } })
|
||||
let defaultsAtRule = atRule.clone()
|
||||
|
||||
// Clone each rule with multiple selectors into their own rule with a single
|
||||
// selector.
|
||||
|
|
@ -312,32 +263,12 @@ export function migrateAtLayerUtilities(): Plugin {
|
|||
|
||||
return {
|
||||
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
|
||||
OnceExit: async (root) => {
|
||||
OnceExit: (root) => {
|
||||
// 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.
|
||||
root.walkAtRules('layer', migrate)
|
||||
|
||||
// Prettier is used to generate cleaner output, but it's only used on the
|
||||
// nodes that were marked as `pretty` during the migration.
|
||||
{
|
||||
// Find the nodes to format
|
||||
let nodesToFormat: ChildNode[] = []
|
||||
walk(root, (child) => {
|
||||
if (child.raws.tailwind_pretty) {
|
||||
nodesToFormat.push(child)
|
||||
return WalkAction.Skip
|
||||
}
|
||||
})
|
||||
|
||||
// Format the nodes
|
||||
await Promise.all(
|
||||
nodesToFormat.map(async (node) => {
|
||||
node.replaceWith(parse(await format(node.toString(), { parser: 'css', semi: true })))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Merge `@utility <name>` with the same name into a single rule. This can
|
||||
// happen when the same classes is used in multiple `@layer utilities`
|
||||
// blocks.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import dedent from 'dedent'
|
||||
import postcss from 'postcss'
|
||||
import { expect, it } from 'vitest'
|
||||
import { formatNodes } from './format-nodes'
|
||||
import { migrateMissingLayers } from './migrate-missing-layers'
|
||||
|
||||
const css = dedent
|
||||
|
||||
function migrate(input: string) {
|
||||
return postcss()
|
||||
.use(migrateMissingLayers())
|
||||
.use(formatNodes())
|
||||
.process(input, { from: expect.getState().testPath })
|
||||
.then((result) => result.css)
|
||||
}
|
||||
|
||||
it('should migrate rules between tailwind directives', async () => {
|
||||
expect(
|
||||
await migrate(css`
|
||||
@tailwind base;
|
||||
|
||||
.base {
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
|
||||
.component-a {
|
||||
}
|
||||
.component-b {
|
||||
}
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
.utility-a {
|
||||
}
|
||||
.utility-b {
|
||||
}
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@tailwind base;
|
||||
|
||||
@layer base {
|
||||
.base {
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.component-a {
|
||||
}
|
||||
.component-b {
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.utility-a {
|
||||
}
|
||||
.utility-b {
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { AtRule, type ChildNode, type Plugin, type Root } from 'postcss'
|
||||
|
||||
export function migrateMissingLayers(): Plugin {
|
||||
function migrate(root: Root) {
|
||||
let lastLayer = ''
|
||||
let bucket: ChildNode[] = []
|
||||
let buckets: [layer: string, bucket: typeof bucket][] = []
|
||||
|
||||
root.each((node) => {
|
||||
if (node.type === 'atrule') {
|
||||
// Known Tailwind directives that should not be inside a layer.
|
||||
if (node.name === 'theme' || node.name === 'utility') {
|
||||
if (bucket.length > 0) {
|
||||
buckets.push([lastLayer, bucket.splice(0)])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Base
|
||||
if (
|
||||
(node.name === 'tailwind' && node.params === 'base') ||
|
||||
(node.name === 'import' && node.params.match(/^["']tailwindcss\/base["']/))
|
||||
) {
|
||||
if (bucket.length > 0) {
|
||||
buckets.push([lastLayer, bucket.splice(0)])
|
||||
}
|
||||
|
||||
lastLayer = 'base'
|
||||
return
|
||||
}
|
||||
|
||||
// Components
|
||||
if (
|
||||
(node.name === 'tailwind' && node.params === 'components') ||
|
||||
(node.name === 'import' && node.params.match(/^["']tailwindcss\/components["']/))
|
||||
) {
|
||||
if (bucket.length > 0) {
|
||||
buckets.push([lastLayer, bucket.splice(0)])
|
||||
}
|
||||
|
||||
lastLayer = 'components'
|
||||
return
|
||||
}
|
||||
|
||||
// Utilities
|
||||
if (
|
||||
(node.name === 'tailwind' && node.params === 'utilities') ||
|
||||
(node.name === 'import' && node.params.match(/^["']tailwindcss\/utilities["']/))
|
||||
) {
|
||||
if (bucket.length > 0) {
|
||||
buckets.push([lastLayer, bucket.splice(0)])
|
||||
}
|
||||
|
||||
lastLayer = 'utilities'
|
||||
return
|
||||
}
|
||||
|
||||
// Already in a layer
|
||||
if (node.name === 'layer') {
|
||||
if (bucket.length > 0) {
|
||||
buckets.push([lastLayer, bucket.splice(0)])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add layer to `@import` at-rules
|
||||
if (node.name === 'import') {
|
||||
if (!node.params.includes('layer(')) {
|
||||
node.params += ` layer(${lastLayer})`
|
||||
}
|
||||
|
||||
if (bucket.length > 0) {
|
||||
buckets.push([lastLayer, bucket.splice(0)])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Track the node
|
||||
if (lastLayer !== '') {
|
||||
if (bucket.push(node) !== 1) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Add the last bucket if it's not empty
|
||||
if (bucket.length > 0) {
|
||||
buckets.push([lastLayer, bucket.splice(0)])
|
||||
}
|
||||
|
||||
// Wrap each bucket in an `@layer` at-rule
|
||||
for (let [layerName, nodes] of buckets) {
|
||||
let layerNode = new AtRule({
|
||||
name: 'layer',
|
||||
params: layerName,
|
||||
nodes,
|
||||
raws: {
|
||||
tailwind_pretty: true,
|
||||
},
|
||||
})
|
||||
nodes[0].replaceWith(layerNode)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
postcssPlugin: '@tailwindcss/upgrade/migrate-missing-layers',
|
||||
OnceExit: migrate,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import dedent from 'dedent'
|
||||
import postcss from 'postcss'
|
||||
import { expect, it } from 'vitest'
|
||||
import { formatNodes } from './format-nodes'
|
||||
import { migrateTailwindDirectives } from './migrate-tailwind-directives'
|
||||
|
||||
const css = dedent
|
||||
|
|
@ -8,6 +9,7 @@ const css = dedent
|
|||
function migrate(input: string) {
|
||||
return postcss()
|
||||
.use(migrateTailwindDirectives())
|
||||
.use(formatNodes())
|
||||
.process(input, { from: expect.getState().testPath })
|
||||
.then((result) => result.css)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,6 @@ export function migrateTailwindDirectives(): Plugin {
|
|||
|
||||
return {
|
||||
postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives',
|
||||
Once: migrate,
|
||||
OnceExit: migrate,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,3 +26,95 @@ it('should print the input as-is', async () => {
|
|||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
it('should migrate a stylesheet', async () => {
|
||||
expect(
|
||||
await migrateContents(css`
|
||||
@tailwind base;
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
|
||||
.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';
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.a {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility b {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.c {
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.d {
|
||||
z-index: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@utility e {
|
||||
z-index: 5;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
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';
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
"@import 'tailwindcss';
|
||||
@import './my-base.css' layer(base);
|
||||
@import './my-components.css' layer(components);
|
||||
@import './my-utilities.css' layer(utilities);"
|
||||
`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import postcss from 'postcss'
|
||||
import { formatNodes } from './codemods/format-nodes'
|
||||
import { migrateAtApply } from './codemods/migrate-at-apply'
|
||||
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) {
|
||||
return postcss()
|
||||
.use(migrateAtApply())
|
||||
.use(migrateTailwindDirectives())
|
||||
.use(migrateAtLayerUtilities())
|
||||
.use(migrateMissingLayers())
|
||||
.use(migrateTailwindDirectives())
|
||||
.use(formatNodes())
|
||||
.process(contents, { from: file })
|
||||
.then((result) => result.css)
|
||||
}
|
||||
|
|
|
|||
42
packages/@tailwindcss-upgrade/src/utils/walk.ts
Normal file
42
packages/@tailwindcss-upgrade/src/utils/walk.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export enum WalkAction {
|
||||
// Continue walking the tree. Default behavior.
|
||||
Continue,
|
||||
|
||||
// Skip walking into the current node.
|
||||
Skip,
|
||||
|
||||
// Stop walking the tree entirely.
|
||||
Stop,
|
||||
}
|
||||
|
||||
interface Walkable<T> {
|
||||
each(cb: (node: T, index: number) => void): void
|
||||
}
|
||||
|
||||
// Custom walk implementation where we can skip going into nodes when we don't
|
||||
// need to process them.
|
||||
export function walk<T>(rule: Walkable<T>, cb: (rule: T) => void | WalkAction): undefined | false {
|
||||
let result: undefined | false = undefined
|
||||
|
||||
rule.each?.((node) => {
|
||||
let action = cb(node) ?? WalkAction.Continue
|
||||
if (action === WalkAction.Stop) {
|
||||
result = false
|
||||
return result
|
||||
}
|
||||
if (action !== WalkAction.Skip) {
|
||||
result = walk(node as Walkable<T>, cb)
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Depth first walk reversal implementation.
|
||||
export function walkDepth<T>(rule: Walkable<T>, cb: (rule: T) => void) {
|
||||
rule?.each?.((node) => {
|
||||
walkDepth(node as Walkable<T>, cb)
|
||||
cb(node)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue