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:
Robin Malfait 2024-09-24 18:32:50 +02:00 • committed by GitHub
parent d14249ddc2
commit d869442a54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 423 additions and 80 deletions

View file

@ -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

View file

@ -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;
}
}
`,
)
},
)

View file

@ -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;
}
}"
`)
})

View 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,
}
}

View file

@ -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)
},
}
}

View file

@ -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)
}

View file

@ -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.

View file

@ -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 {
}
}"
`)
})

View file

@ -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,
}
}

View file

@ -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)
}

View file

@ -86,6 +86,6 @@ export function migrateTailwindDirectives(): Plugin {
return {
postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives',
Once: migrate,
OnceExit: migrate,
}
}

View file

@ -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);"
`)
})

View file

@ -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)
}

View 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)
})
}