Sort upgraded CSS (#14866)

During the migration process, a lot of changes to the CSS file happen.
Some parts are converted, some parts are deleted and some new CSS is
added.

To make sure we are generating a sensible and good looking CSS file, we
will sort the final CSS and pretty print it.

The order we came up with looks like this:

```css
/* Imports */
@import "tailwindcss";
@import "../other.css";

/* Configuration */
@config "../path/to/tailwindcss.config.js";

@plugin "my-plugin-1";
@plugin "my-plugin-2";

@source "./foo/**/*.ts";
@source "./bar/**/*.ts";

@variant foo {}
@variant bar {}

@theme {}

/* Border compatibility CSS */
@layer base {}

/* Utilities */
@utility foo {}
@utility bar {}

/* Rest of your own CSS if any */
```

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
Robin Malfait 2024-11-07 13:04:52 +01:00 committed by GitHub
parent 26638af3ef
commit 462308d8d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 488 additions and 281 deletions

View File

@ -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,44 +1497,11 @@ test(
border-width: 0;
}
}
@config "../tailwind.config.ts";
--- ./src/root.3.css ---
/* Inject missing @config above first @theme */
@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;
}
}
@config '../tailwind.config.ts';
@variant hocus (&:hover, &:focus);
@ -1537,10 +1514,45 @@ test(
--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
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/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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Stylesheet, string[]>()
@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,11 +15,14 @@ interface Walkable<T> {
// 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 {
export function walk<T>(
rule: Walkable<T>,
cb: (rule: T, idx: number, parent: Walkable<T>) => 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