diff --git a/CHANGELOG.md b/CHANGELOG.md index 6922f5bfd3eb..108fc3a26274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/cli/upgrade.test.ts b/integrations/cli/upgrade.test.ts index 72b1bf4a8a2f..9629a685eb1e 100644 --- a/integrations/cli/upgrade.test.ts +++ b/integrations/cli/upgrade.test.ts @@ -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; + } + } + `, + ) }, ) diff --git a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts new file mode 100644 index 000000000000..517c517c0cad --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts @@ -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; + } + }" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts new file mode 100644 index 000000000000..e90e0e2637ca --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts @@ -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, + } +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts index 41ff03ec3323..ebea30e96d6a 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-apply.ts @@ -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) }, } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts index a493639f2ea3..6f1a03301f93 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.test.ts @@ -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) } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts index 2bc0c9601cbf..c1e383ef5356 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts @@ -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 { - 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(rule: Walkable, 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, cb) - return result - } - }) - - return result -} - -// Depth first walk reversal implementation. -function walkDepth(rule: Walkable, cb: (rule: T) => void) { - rule?.each?.((node) => { - walkDepth(node as Walkable, 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 ` with the same name into a single rule. This can // happen when the same classes is used in multiple `@layer utilities` // blocks. diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts new file mode 100644 index 000000000000..c33bcc03e1d3 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts @@ -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 { + } + }" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts new file mode 100644 index 000000000000..3c1818ff24a4 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts @@ -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, + } +} diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts index f59fef01e848..9f4ae095501d 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts @@ -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) } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts index cb47714a0197..5bb4a34e2349 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.ts @@ -86,6 +86,6 @@ export function migrateTailwindDirectives(): Plugin { return { postcssPlugin: '@tailwindcss/upgrade/migrate-tailwind-directives', - Once: migrate, + OnceExit: migrate, } } diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts index a043092d9ae6..2f4f7475e3f6 100644 --- a/packages/@tailwindcss-upgrade/src/index.test.ts +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -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);" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 88c54350db5f..cfbe50d3dcb3 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -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) } diff --git a/packages/@tailwindcss-upgrade/src/utils/walk.ts b/packages/@tailwindcss-upgrade/src/utils/walk.ts new file mode 100644 index 000000000000..7a86b7ae533d --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/utils/walk.ts @@ -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 { + 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(rule: Walkable, 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, cb) + return result + } + }) + + return result +} + +// Depth first walk reversal implementation. +export function walkDepth(rule: Walkable, cb: (rule: T) => void) { + rule?.each?.((node) => { + walkDepth(node as Walkable, cb) + cb(node) + }) +}