diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 1489243a68b1..9784f57c98b7 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -398,7 +398,6 @@ test( If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ - @layer base { *, ::after, @@ -1002,6 +1001,7 @@ test( border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -1193,6 +1193,7 @@ test( --- ./src/a.1.utilities.1.css --- @import './a.1.utilities.utilities.css'; + @utility foo-from-a { color: red; } @@ -1214,12 +1215,14 @@ test( --- ./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; } @@ -1229,12 +1232,14 @@ test( --- ./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; } @@ -1417,6 +1422,8 @@ test( /* Inject missing @config */ @import 'tailwindcss'; + @config '../tailwind.config.ts'; + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -1434,6 +1441,7 @@ test( border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -1449,12 +1457,13 @@ test( border-width: 0; } } - @config '../tailwind.config.ts'; --- ./src/root.2.css --- /* Already contains @config */ @import 'tailwindcss'; + @config "../tailwind.config.ts"; + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -1472,6 +1481,7 @@ test( border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -1487,12 +1497,23 @@ test( border-width: 0; } } - @config "../tailwind.config.ts"; --- ./src/root.3.css --- /* Inject missing @config above first @theme */ @import 'tailwindcss'; + @config '../tailwind.config.ts'; + + @variant hocus (&:hover, &:focus); + + @theme { + --color-red-500: #f00; + } + + @theme { + --color-blue-500: #00f; + } + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -1510,6 +1531,7 @@ test( border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -1525,22 +1547,12 @@ test( border-width: 0; } } - @config '../tailwind.config.ts'; - - @variant hocus (&:hover, &:focus); - - @theme { - --color-red-500: #f00; - } - - @theme { - --color-blue-500: #00f; - } --- ./src/root.4.css --- /* Inject missing @config due to nested imports with tailwind imports */ @import './root.4/base.css'; @import './root.4/utilities.css'; + @config '../tailwind.config.ts'; --- ./src/root.5.css --- @@ -1591,6 +1603,8 @@ test( /* Inject missing @config in this file, due to full import */ @import 'tailwindcss'; + @config '../../tailwind.config.ts'; + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -1608,6 +1622,7 @@ test( border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -1623,7 +1638,6 @@ test( border-width: 0; } } - @config '../../tailwind.config.ts'; " `) }, @@ -1681,6 +1695,7 @@ test( border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 12f52494bcc1..bee8044cbad7 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -144,40 +144,6 @@ test( --- src/input.css --- @import 'tailwindcss'; - /* - The default border color has changed to \`currentColor\` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. - */ - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } - } - - /* - Form elements have a 1px border by default in Tailwind CSS v4, so we've - added these compatibility styles to make sure everything still looks the - same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add \`border-0\` to - any form elements that shouldn't have a border. - */ - @layer base { - input:where(:not([type='button'], [type='reset'], [type='submit'])), - select, - textarea { - border-width: 0; - } - } - @source '../node_modules/my-external-lib/**/*.{html}'; @variant dark (&:where(.dark, .dark *)); @@ -274,6 +240,40 @@ test( } } + /* + The default border color has changed to \`currentColor\` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. + */ + @layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } + } + + /* + Form elements have a 1px border by default in Tailwind CSS v4, so we've + added these compatibility styles to make sure everything still looks the + same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add \`border-0\` to + any form elements that shouldn't have a border. + */ + @layer base { + input:where(:not([type='button'], [type='reset'], [type='submit'])), + select, + textarea { + border-width: 0; + } + } + --- src/test.js --- export default { 'shouldNotMigrate': !border.test + '', @@ -346,6 +346,24 @@ test( --- src/input.css --- @import 'tailwindcss'; + @plugin '@tailwindcss/typography'; + @plugin '../custom-plugin' { + is-null: null; + is-true: true; + is-false: false; + is-int: 1234567; + is-float: 1.35; + is-sci: 0.0000135; + is-str-null: 'null'; + is-str-true: 'true'; + is-str-false: 'false'; + is-str-int: '1234567'; + is-str-float: '1.35'; + is-str-sci: '1.35e-5'; + is-arr: 'foo', 'bar'; + is-arr-mixed: null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'; + } + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -379,24 +397,6 @@ test( border-width: 0; } } - - @plugin '@tailwindcss/typography'; - @plugin '../custom-plugin' { - is-null: null; - is-true: true; - is-false: false; - is-int: 1234567; - is-float: 1.35; - is-sci: 0.0000135; - is-str-null: 'null'; - is-str-true: 'true'; - is-str-false: 'false'; - is-str-int: '1234567'; - is-str-float: '1.35'; - is-str-sci: '1.35e-5'; - is-arr: 'foo', 'bar'; - is-arr-mixed: null, true, false, 1234567, 1.35, 'foo', 'bar', 'true'; - } " `) @@ -447,6 +447,20 @@ test( --- src/input.css --- @import 'tailwindcss'; + @theme { + --color-gray-50: oklch(0.985 0 0); + --color-gray-100: oklch(0.97 0 0); + --color-gray-200: oklch(0.922 0 0); + --color-gray-300: oklch(0.87 0 0); + --color-gray-400: oklch(0.708 0 0); + --color-gray-500: oklch(0.556 0 0); + --color-gray-600: oklch(0.439 0 0); + --color-gray-700: oklch(0.371 0 0); + --color-gray-800: oklch(0.269 0 0); + --color-gray-900: oklch(0.205 0 0); + --color-gray-950: oklch(0.145 0 0); + } + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -480,20 +494,6 @@ test( border-width: 0; } } - - @theme { - --color-gray-50: oklch(0.985 0 0); - --color-gray-100: oklch(0.97 0 0); - --color-gray-200: oklch(0.922 0 0); - --color-gray-300: oklch(0.87 0 0); - --color-gray-400: oklch(0.708 0 0); - --color-gray-500: oklch(0.556 0 0); - --color-gray-600: oklch(0.439 0 0); - --color-gray-700: oklch(0.371 0 0); - --color-gray-800: oklch(0.269 0 0); - --color-gray-900: oklch(0.205 0 0); - --color-gray-950: oklch(0.145 0 0); - } " `) @@ -548,6 +548,8 @@ test( --- src/input.css --- @import 'tailwindcss'; + @config '../tailwind.config.ts'; + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -581,7 +583,6 @@ test( border-width: 0; } } - @config '../tailwind.config.ts'; " `) @@ -640,6 +641,8 @@ test( --- src/input.css --- @import 'tailwindcss'; + @config '../tailwind.config.ts'; + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -673,7 +676,6 @@ test( border-width: 0; } } - @config '../tailwind.config.ts'; " `) @@ -728,6 +730,8 @@ test( --- src/input.css --- @import 'tailwindcss'; + @config '../tailwind.config.ts'; + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -761,7 +765,6 @@ test( border-width: 0; } } - @config '../tailwind.config.ts'; " `) @@ -852,6 +855,10 @@ test( --- project-a/src/input.css --- @import 'tailwindcss'; + @theme { + --color-primary: red; + } + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -886,13 +893,13 @@ test( } } - @theme { - --color-primary: red; - } - --- project-b/src/input.css --- @import 'tailwindcss'; + @theme { + --color-primary: blue; + } + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -926,10 +933,6 @@ test( border-width: 0; } } - - @theme { - --color-primary: blue; - } " `) }, diff --git a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts index 517c517c0cad..9d6393a02b82 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.test.ts @@ -1,12 +1,13 @@ import postcss, { type Plugin } from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './format-nodes' +import { sortBuckets } from './sort-buckets' function markPretty(): Plugin { return { postcssPlugin: '@tailwindcss/upgrade/mark-pretty', OnceExit(root) { - root.walkAtRules('utility', (atRule) => { + root.walkAtRules('tw-format', (atRule) => { atRule.raws.tailwind_pretty = true }) }, @@ -16,16 +17,14 @@ function markPretty(): Plugin { function migrate(input: string) { return postcss() .use(markPretty()) + .use(sortBuckets()) .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(` +it('should format PostCSS nodes', async () => { + expect(await migrate(`@utility .foo { .foo { color: red; } }`)).toMatchInlineSnapshot(` "@utility .foo { .foo { color: red; @@ -33,3 +32,14 @@ it('should format PostCSS nodes that are marked with tailwind_pretty', async () }" `) }) + +it('should format PostCSS nodes in the `user` bucket', async () => { + expect(await migrate(`@tw-bucket user { @tw-format .bar { .foo { color: red; } } }`)) + .toMatchInlineSnapshot(` + "@tw-format .bar { + .foo { + color: red; + } + }" + `) +}) diff --git a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts index b545d6009e72..a5c98050576d 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/format-nodes.ts @@ -1,6 +1,12 @@ -import { parse, type ChildNode, type Plugin, type Root } from 'postcss' -import { format } from 'prettier' -import { walk, WalkAction } from '../utils/walk' +import postcss, { type ChildNode, type Plugin, type Root } from 'postcss' +import { format, type Options } from 'prettier' +import { walk } from '../utils/walk' + +const FORMAT_OPTIONS: Options = { + parser: 'css', + semi: true, + singleQuote: true, +} // Prettier is used to generate cleaner output, but it's only used on the nodes // that were marked as `pretty` during the migration. @@ -8,26 +14,66 @@ 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) { + walk(root, (child, _idx, parent) => { + // Always print semicolons after at-rules + if (child.type === 'atrule') { + child.raws.semicolon = true + } + + if (child.type === 'atrule' && child.name === 'tw-bucket') { nodesToFormat.push(child) - return WalkAction.Skip + } else if (child.raws.tailwind_pretty) { + // @ts-expect-error We might not have a parent + child.parent ??= parent + nodesToFormat.unshift(child) } }) + let output: string[] = [] + // Format the nodes - await Promise.all( - nodesToFormat.map(async (node) => { - node.replaceWith( - parse( - await format(node.toString(), { - parser: 'css', - semi: true, - singleQuote: true, - }), - ), - ) - }), + for (let node of nodesToFormat) { + let contents = (() => { + if (node.type === 'atrule' && node.name === 'tw-bucket') { + // Remove the `@tw-bucket` wrapping, and use the contents directly. + return node + .toString() + .trim() + .replace(/@tw-bucket(.*?){([\s\S]*)}/, '$2') + } + + return node.toString() + })() + + // Do not format the user bucket to ensure we keep the user's formatting + // intact. + if (node.type === 'atrule' && node.name === 'tw-bucket' && node.params === 'user') { + output.push(contents) + continue + } + + // Format buckets + if (node.type === 'atrule' && node.name === 'tw-bucket') { + output.push(await format(contents, FORMAT_OPTIONS)) + continue + } + + // Format any other nodes + node.replaceWith( + postcss.parse( + `${node.raws.before ?? ''}${(await format(contents, FORMAT_OPTIONS)).trim()}`, + ), + ) + } + + root.removeAll() + root.append( + postcss.parse( + output + .map((bucket) => bucket.trim()) + .filter(Boolean) + .join('\n\n'), + ), ) } 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 d8134cf48421..17aeb857722e 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 @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { Stylesheet } from '../stylesheet' import { formatNodes } from './format-nodes' import { migrateAtLayerUtilities } from './migrate-at-layer-utilities' +import { sortBuckets } from './sort-buckets' const css = dedent @@ -33,6 +34,7 @@ async function migrate( return postcss() .use(migrateAtLayerUtilities(stylesheet)) + .use(sortBuckets()) .use(formatNodes()) .process(stylesheet.root!, { from: expect.getState().testPath }) .then((result) => result.css) @@ -145,7 +147,18 @@ it('should leave non-class utilities alone', async () => { } `), ).toMatchInlineSnapshot(` - "@layer utilities { + "@utility foo { + /* 2. */ + /* 2.1. */ + color: red; + /* 2.2. */ + .bar { + /* 2.2.1. */ + font-weight: bold; + } + } + + @layer utilities { /* 1. */ #before { /* 1.1. */ @@ -167,17 +180,6 @@ it('should leave non-class utilities alone', async () => { font-weight: bold; } } - } - - @utility foo { - /* 2. */ - /* 2.1. */ - color: red; - /* 2.2. */ - .bar { - /* 2.2.1. */ - font-weight: bold; - } }" `) }) @@ -776,13 +778,7 @@ describe('comments', () => { /* After */ `), ).toMatchInlineSnapshot(` - "/* Above */ - .before { - /* Inside */ - } - /* After */ - - /* Tailwind Utilities: */ + "/* Tailwind Utilities: */ @utility no-scrollbar { /* Chrome, Safari and Opera */ /* Second comment */ @@ -799,6 +795,12 @@ describe('comments', () => { scrollbar-width: none; /* Firefox */ } + /* Above */ + .before { + /* Inside */ + } + /* After */ + /* Above */ .after { /* Inside */ @@ -925,14 +927,13 @@ describe('layered stylesheets', () => { layers: ['utilities'], }), ).toMatchInlineSnapshot(` - " - #main { + "@utility foo { + /* Utility #1 */ + /* Declarations: */ color: red; } - @utility foo { - /* Utility #1 */ - /* Declarations: */ + #main { color: red; }" `) @@ -975,18 +976,7 @@ describe('layered stylesheets', () => { layers: ['utilities'], }), ).toMatchInlineSnapshot(` - "@layer utilities { - - #main { - color: red; - } - } - - #secondary { - color: red; - } - - @utility foo { + "@utility foo { @layer utilities { @layer utilities { /* Utility #1 */ @@ -1008,6 +998,17 @@ describe('layered stylesheets', () => { /* Utility #3 */ /* Declarations: */ color: red; + } + + @layer utilities { + + #main { + color: red; + } + } + + #secondary { + color: red; }" `) }) 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 e274dc202155..f0f11d3d66cb 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts @@ -191,8 +191,6 @@ export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin { clone.name = 'utility' clone.params = cls - // Mark the node as pretty so that it gets formatted by Prettier later. - clone.raws.tailwind_pretty = true clone.raws.before = `${clone.raws.before ?? ''}\n\n` } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.test.ts index 65bbaac3dbc0..fae0db2b37de 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.test.ts @@ -4,6 +4,7 @@ import postcss from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './format-nodes' import { migrateBorderCompatibility } from './migrate-border-compatibility' +import { sortBuckets } from './sort-buckets' const css = dedent @@ -17,6 +18,7 @@ async function migrate(input: string) { return postcss() .use(migrateBorderCompatibility({ designSystem })) + .use(sortBuckets()) .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) @@ -95,6 +97,7 @@ it('should add the compatibility CSS after the last `@import`', async () => { border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -156,6 +159,7 @@ it('should add the compatibility CSS after the last import, even if a body-less border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -200,9 +204,6 @@ it('should add the compatibility CSS before the first `@layer base` (if the "tai @variant foo { } - @utility bar { - } - /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -211,7 +212,6 @@ it('should add the compatibility CSS before the first `@layer base` (if the "tai If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ - @layer base { *, ::after, @@ -238,12 +238,15 @@ it('should add the compatibility CSS before the first `@layer base` (if the "tai } } - @layer base { + @utility bar { } @utility baz { } + @layer base { + } + @layer base { }" `) @@ -275,9 +278,6 @@ it('should add the compatibility CSS before the first `@layer base` (if the "tai @variant foo { } - @utility bar { - } - /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -286,7 +286,6 @@ it('should add the compatibility CSS before the first `@layer base` (if the "tai If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ - @layer base { *, ::after, @@ -313,12 +312,15 @@ it('should add the compatibility CSS before the first `@layer base` (if the "tai } } - @layer base { + @utility bar { } @utility baz { } + @layer base { + } + @layer base { }" `) @@ -349,10 +351,10 @@ it('should not add the backwards compatibility CSS when no `@import "tailwindcss @utility bar { } - @layer base { + @utility baz { } - @utility baz { + @layer base { } @layer base { @@ -389,10 +391,10 @@ it('should not add the backwards compatibility CSS when another `@import "tailwi @utility bar { } - @layer base { + @utility baz { } - @utility baz { + @layer base { } @layer base { diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.ts index f6e7b1e38246..a3165ae77915 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-border-compatibility.ts @@ -1,5 +1,5 @@ import dedent from 'dedent' -import postcss, { AtRule, type Plugin, type Root } from 'postcss' +import postcss, { type Plugin, type Root } from 'postcss' import type { Config } from 'tailwindcss' import { keyPathToCssProperty } from '../../../tailwindcss/src/compat/apply-config-to-theme' import type { DesignSystem } from '../../../tailwindcss/src/design-system' @@ -77,19 +77,6 @@ export function migrateBorderCompatibility({ if (!isTailwindRoot) return - let targetNode = null as AtRule | null - - root.walkAtRules((node) => { - if (node.name === 'import') { - targetNode = node - } else if (node.name === 'layer' && node.params === 'base') { - targetNode = node - return false - } - }) - - if (!targetNode) return - // Figure out the compatibility CSS to inject let compatibilityCssString = '' if (defaultBorderColor !== DEFAULT_BORDER_COLOR) { @@ -98,6 +85,7 @@ export function migrateBorderCompatibility({ } compatibilityCssString += BORDER_WIDTH_COMPATIBILITY_CSS + compatibilityCssString = `\n@tw-bucket compatibility {\n${compatibilityCssString}\n}\n` let compatibilityCss = postcss.parse(compatibilityCssString) // Replace the `theme(…)` with v3 values if we can't resolve the theme @@ -129,19 +117,7 @@ export function migrateBorderCompatibility({ }) // Inject the compatibility CSS - if (targetNode.name === 'import') { - targetNode.after(compatibilityCss) - - let next = targetNode.next() - if (next) next.raws.before = '\n\n' - } else { - let rawsBefore = compatibilityCss.last?.raws.before - - targetNode.before(compatibilityCss) - - let prev = targetNode.prev() - if (prev) prev.raws.before = rawsBefore - } + root.append(compatibilityCss) } return { diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts index b6a43eec2f1b..d5557ec997bd 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-config.ts @@ -3,7 +3,6 @@ import postcss, { AtRule, type Plugin, Root } from 'postcss' import { normalizePath } from '../../../@tailwindcss-node/src/normalize-path' import type { JSConfigMigration } from '../migrate-js-config' import type { Stylesheet } from '../stylesheet' -import { walk, WalkAction } from '../utils/walk' const ALREADY_INJECTED = new WeakMap() @@ -39,14 +38,14 @@ export function migrateConfig( }) let css = '\n\n' + css += '\n@tw-bucket source {' for (let source of jsConfigMigration.sources) { let absolute = path.resolve(source.base, source.pattern) css += `@source '${relativeToStylesheet(sheet, absolute)}';\n` } - if (jsConfigMigration.sources.length > 0) { - css = css + '\n' - } + css += '}\n' + css += '\n@tw-bucket plugin {\n' for (let plugin of jsConfigMigration.plugins) { let relative = plugin.path[0] === '.' @@ -71,37 +70,16 @@ export function migrateConfig( css += ` ${property}: ${cssValue};\n` } - css += '}\n' + css += '}\n' // @plugin } } - if (jsConfigMigration.plugins.length > 0) { - css = css + '\n' - } + css += '}\n' // @tw-bucket cssConfig.append(postcss.parse(css + jsConfigMigration.css)) } - // Inject the `@config` directive after the last `@import` or at the - // top of the file if no `@import` rules are present - let locationNode = null as AtRule | null - - walk(root, (node) => { - if (node.type === 'atrule' && node.name === 'import') { - locationNode = node - } - - return WalkAction.Skip - }) - - for (let node of cssConfig?.nodes ?? []) { - node.raws.tailwind_pretty = true - } - - if (!locationNode) { - root.prepend(cssConfig.nodes) - } else if (locationNode.name === 'import') { - locationNode.after(cssConfig.nodes) - } + // Inject the `@config` directive + root.append(cssConfig.nodes) } function migrate(root: Root) { diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-media-screen.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-media-screen.test.ts index d0d2312076bb..f4afd15fa268 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-media-screen.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-media-screen.test.ts @@ -5,6 +5,7 @@ import { expect, it } from 'vitest' import type { UserConfig } from '../../../tailwindcss/src/compat/config/types' import { formatNodes } from './format-nodes' import { migrateMediaScreen } from './migrate-media-screen' +import { sortBuckets } from './sort-buckets' const css = dedent @@ -18,6 +19,7 @@ async function migrate(input: string, userConfig: UserConfig = {}) { userConfig, }), ) + .use(sortBuckets()) .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts index d2782863a731..307702b16c34 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.test.ts @@ -3,12 +3,14 @@ import postcss from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './format-nodes' import { migrateMissingLayers } from './migrate-missing-layers' +import { sortBuckets } from './sort-buckets' const css = dedent function migrate(input: string) { return postcss() .use(migrateMissingLayers()) + .use(sortBuckets()) .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) @@ -122,15 +124,15 @@ it('should migrate rules above the `@tailwind base` directive in an `@layer base * License header */ + @tailwind base; + @tailwind components; + @tailwind utilities; + @layer base { html { color: red; } - } - - @tailwind base; - @tailwind components; - @tailwind utilities;" + }" `) }) @@ -159,13 +161,15 @@ it('should migrate rules between tailwind directives', async () => { ).toMatchInlineSnapshot(` "@tailwind base; + @tailwind components; + + @tailwind utilities; + @layer base { .base { } } - @tailwind components; - @layer components { .component-a { } @@ -173,8 +177,6 @@ it('should migrate rules between tailwind directives', async () => { } } - @tailwind utilities; - .utility-a { } .utility-b { 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 7c74b1ed6e9c..fdf480b3ef3d 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-tailwind-directives.test.ts @@ -3,12 +3,14 @@ import postcss from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './format-nodes' import { migrateTailwindDirectives } from './migrate-tailwind-directives' +import { sortBuckets } from './sort-buckets' const css = dedent function migrate(input: string, options: { newPrefix: string | null } = { newPrefix: null }) { return postcss() .use(migrateTailwindDirectives(options)) + .use(sortBuckets()) .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-theme-to-var.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-theme-to-var.test.ts index 2bd35ce8e8b7..ce3940bc907e 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-theme-to-var.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-theme-to-var.test.ts @@ -4,6 +4,7 @@ import postcss from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './format-nodes' import { migrateThemeToVar } from './migrate-theme-to-var' +import { sortBuckets } from './sort-buckets' const css = dedent @@ -16,6 +17,7 @@ async function migrate(input: string) { }), }), ) + .use(sortBuckets()) .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-variants-directive.test.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-variants-directive.test.ts index e18a161e6539..3521d34f7039 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-variants-directive.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-variants-directive.test.ts @@ -3,12 +3,14 @@ import postcss from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './format-nodes' import { migrateVariantsDirective } from './migrate-variants-directive' +import { sortBuckets } from './sort-buckets' const css = dedent function migrate(input: string) { return postcss() .use(migrateVariantsDirective()) + .use(sortBuckets()) .use(formatNodes()) .process(input, { from: expect.getState().testPath }) .then((result) => result.css) diff --git a/packages/@tailwindcss-upgrade/src/codemods/sort-buckets.ts b/packages/@tailwindcss-upgrade/src/codemods/sort-buckets.ts new file mode 100644 index 000000000000..b99badeb01b3 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/codemods/sort-buckets.ts @@ -0,0 +1,161 @@ +import postcss, { type AtRule, type ChildNode, type Comment, type Plugin, type Root } from 'postcss' +import { DefaultMap } from '../../../tailwindcss/src/utils/default-map' +import { walk, WalkAction } from '../utils/walk' + +const BUCKET_ORDER = [ + // Imports + 'import', // @import + + // Configuration + 'config', // @config + 'plugin', // @plugin + 'source', // @source + 'variant', // @variant + 'theme', // @theme + + // Styles + 'compatibility', // @layer base with compatibility CSS + 'utility', // @utility + + // User CSS + 'user', +] + +export function sortBuckets(): Plugin { + async function migrate(root: Root) { + // 1. Move items that are not in a bucket, into a bucket + { + let comments: Comment[] = [] + + let buckets = new DefaultMap((name) => { + let bucket = postcss.atRule({ name: 'tw-bucket', params: name, nodes: [] }) + root.append(bucket) + return bucket + }) + + // Seed the buckets with existing buckets + root.walkAtRules('tw-bucket', (node) => { + buckets.set(node.params, node) + }) + + let lastLayer = 'user' + function injectInto(name: string, ...nodes: ChildNode[]) { + lastLayer = name + buckets.get(name).nodes?.push(...comments.splice(0), ...nodes) + } + + walk(root, (node) => { + // Already in a bucket, skip it + if (node.type === 'atrule' && node.name === 'tw-bucket') { + return WalkAction.Skip + } + + // Comments belong to the bucket of the nearest node, which is typically + // in the "next" bucket. + if (node.type === 'comment') { + // We already have comments, which means that we already have nodes + // that belong in the next bucket, so we should move the current + // comment into the next bucket as well. + if (comments.length > 0) { + comments.push(node) + return + } + + // Figure out the closest node to the comment + let prevDistance = distance(node.prev(), node) ?? Infinity + let nextDistance = distance(node, node.next()) ?? Infinity + + if (prevDistance < nextDistance) { + buckets.get(lastLayer).nodes?.push(node) + } else { + comments.push(node) + } + } + + // Known at-rules + else if ( + node.type === 'atrule' && + ['config', 'plugin', 'source', 'theme', 'utility', 'variant'].includes(node.name) + ) { + injectInto(node.name, node) + } + + // Imports bucket, which also contains the `@charset` and body-less `@layer` + else if ( + (node.type === 'atrule' && node.name === 'layer' && !node.nodes) || // @layer foo, bar; + (node.type === 'atrule' && node.name === 'import') || + (node.type === 'atrule' && node.name === 'charset') || // @charset "UTF-8"; + (node.type === 'atrule' && node.name === 'tailwind') + ) { + injectInto('import', node) + } + + // User CSS + else if (node.type === 'rule' || node.type === 'atrule') { + injectInto('user', node) + } + + // Fallback + else { + injectInto('user', node) + } + + return WalkAction.Skip + }) + + if (comments.length > 0) { + injectInto(lastLayer) + } + } + + // 2. Merge `@tw-bucket` with the same name together + let firstBuckets = new Map() + root.walkAtRules('tw-bucket', (node) => { + let firstBucket = firstBuckets.get(node.params) + if (!firstBucket) { + firstBuckets.set(node.params, node) + return + } + + if (node.nodes) { + firstBucket.append(...node.nodes) + } + }) + + // 3. Remove empty `@tw-bucket` + root.walkAtRules('tw-bucket', (node) => { + if (!node.nodes?.length) { + node.remove() + } + }) + + // 4. Sort the `@tw-bucket` themselves + { + let sorted = Array.from(firstBuckets.values()).sort((a, z) => { + let aIndex = BUCKET_ORDER.indexOf(a.params) + let zIndex = BUCKET_ORDER.indexOf(z.params) + return aIndex - zIndex + }) + + // Re-inject the sorted buckets + root.removeAll() + root.append(sorted) + } + } + + return { + postcssPlugin: '@tailwindcss/upgrade/sort-buckets', + OnceExit: migrate, + } +} + +function distance(before?: ChildNode, after?: ChildNode): number | null { + if (!before || !after) return null + if (!before.source || !after.source) return null + if (!before.source.start || !after.source.start) return null + if (!before.source.end || !after.source.end) return null + + // Compare end of Before, to start of After + let d = Math.abs(before.source.end.line - after.source.start.line) + return d +} diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts index f3c06d61a604..519508297594 100644 --- a/packages/@tailwindcss-upgrade/src/index.test.ts +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -4,6 +4,7 @@ import path from 'node:path' import postcss from 'postcss' import { expect, it } from 'vitest' import { formatNodes } from './codemods/format-nodes' +import { sortBuckets } from './codemods/sort-buckets' import { migrateContents } from './migrate' const css = dedent @@ -25,7 +26,7 @@ let config = { function migrate(input: string, config: any) { return migrateContents(input, config, expect.getState().testPath) - .then((result) => postcss([formatNodes()]).process(result.root, result.opts)) + .then((result) => postcss([sortBuckets(), formatNodes()]).process(result.root, result.opts)) .then((result) => result.css) } @@ -103,7 +104,6 @@ it('should migrate a stylesheet', async () => { If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ - @layer base { *, ::after, @@ -130,6 +130,14 @@ it('should migrate a stylesheet', async () => { } } + @utility b { + z-index: 2; + } + + @utility e { + z-index: 5; + } + @layer base { html { overflow: hidden; @@ -142,10 +150,6 @@ it('should migrate a stylesheet', async () => { } } - @utility b { - z-index: 2; - } - @layer components { .c { z-index: 3; @@ -156,10 +160,6 @@ it('should migrate a stylesheet', async () => { .d { z-index: 4; } - } - - @utility e { - z-index: 5; }" `) }) @@ -200,6 +200,7 @@ it('should migrate a stylesheet (with imports)', async () => { border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -239,6 +240,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in @layer foo, bar, baz; /**! My license comment */ @import 'tailwindcss'; + /* The default border color has changed to \`currentColor\` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -256,6 +258,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in border-color: var(--color-gray-200, currentColor); } } + /* Form elements have a 1px border by default in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the @@ -271,6 +274,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in border-width: 0; } } + @layer base { html { color: red; @@ -296,12 +300,12 @@ it('should keep CSS as-is before existing `@layer` at-rules', async () => { config, ), ).toMatchInlineSnapshot(` - ".foo { - color: blue; + "@utility bar { + color: red; } - @utility bar { - color: red; + .foo { + color: blue; }" `) }) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index a059c7461395..b2c9df5cc966 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -5,6 +5,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import postcss from 'postcss' import { formatNodes } from './codemods/format-nodes' +import { sortBuckets } from './codemods/sort-buckets' import { help } from './commands/help' import { analyze as analyzeStylesheets, @@ -223,7 +224,7 @@ async function run() { // Format nodes for (let sheet of stylesheets) { - await postcss([formatNodes()]).process(sheet.root!, { from: sheet.file! }) + await postcss([sortBuckets(), formatNodes()]).process(sheet.root!, { from: sheet.file! }) } // Write all files to disk diff --git a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts index 047a6bfb680b..be33b726bb4e 100644 --- a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts @@ -102,7 +102,8 @@ async function migrateTheme( ) let prevSectionKey = '' - let css = `@theme {` + let css = '\n@tw-bucket theme {\n' + css += `\n@theme {\n` let containsThemeKeys = false for (let [key, value] of themeableValues(resolvedConfig.theme)) { if (typeof value !== 'string' && typeof value !== 'number') { @@ -143,7 +144,10 @@ async function migrateTheme( return null } - return css + '}\n' + css += '}\n' // @theme + css += '}\n' // @tw-bucket + + return css } function migrateDarkMode(unresolvedConfig: Config & { darkMode: any }): string { @@ -155,7 +159,7 @@ function migrateDarkMode(unresolvedConfig: Config & { darkMode: any }): string { if (variant === '') { return '' } - return `@variant dark (${variant});\n` + return `\n@tw-bucket variant {\n@variant dark (${variant});\n}\n` } // Returns a string identifier used to section theme declarations diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 14c9ec1588b3..e75dd3328d65 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -419,11 +419,7 @@ export async function split(stylesheets: Stylesheet[]) { } } - let utilities = postcss.root({ - raws: { - tailwind_pretty: true, - }, - }) + let utilities = postcss.root() walk(sheet.root, (node) => { if (node.type !== 'atrule') return @@ -511,7 +507,6 @@ export async function split(stylesheets: Stylesheet[]) { let newImport = node.clone({ params: `${quote}${newFile}${quote}`, raws: { - after: '\n\n', tailwind_injected_layer: node.raws.tailwind_injected_layer, tailwind_original_params: `${quote}${id}${quote}`, tailwind_destination_sheet_id: utilityDestination.id, diff --git a/packages/@tailwindcss-upgrade/src/utils/walk.ts b/packages/@tailwindcss-upgrade/src/utils/walk.ts index 7a86b7ae533d..4f34b13a09a4 100644 --- a/packages/@tailwindcss-upgrade/src/utils/walk.ts +++ b/packages/@tailwindcss-upgrade/src/utils/walk.ts @@ -15,11 +15,14 @@ interface Walkable { // 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 { +export function walk( + rule: Walkable, + cb: (rule: T, idx: number, parent: Walkable) => void | WalkAction, +): undefined | false { let result: undefined | false = undefined - rule.each?.((node) => { - let action = cb(node) ?? WalkAction.Continue + rule.each?.((node, idx) => { + let action = cb(node, idx, rule) ?? WalkAction.Continue if (action === WalkAction.Stop) { result = false return result