diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 37c327faebe2..8835c5483f36 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -32,15 +32,15 @@ test( async ({ exec, fs }) => { await exec('npx @tailwindcss/upgrade') - await fs.expectFileToContain( - 'src/index.html', - html` -

🤠👋

-
- `, - ) - - await fs.expectFileToContain('src/input.css', css`@import 'tailwindcss';`) + expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` + " + --- ./src/index.html --- +

🤠👋

+
+ + --- ./src/input.css --- + @import 'tailwindcss';" + `) let packageJsonContent = await fs.read('package.json') let packageJson = JSON.parse(packageJsonContent) @@ -86,23 +86,19 @@ test( async ({ exec, fs }) => { await exec('npx @tailwindcss/upgrade') - await fs.expectFileToContain( - 'src/index.html', - html` -

🤠👋

-
- `, - ) + expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` + " + --- ./src/index.html --- +

🤠👋

+
- await fs.expectFileToContain('src/input.css', css` @import 'tailwindcss' prefix(tw); `) - await fs.expectFileToContain( - 'src/input.css', - css` - .btn { - @apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white; - } - `, - ) + --- ./src/input.css --- + @import 'tailwindcss' prefix(tw); + + .btn { + @apply tw:rounded-md! tw:px-2 tw:py-1 tw:bg-blue-500 tw:text-white; + }" + `) }, ) @@ -139,22 +135,23 @@ test( async ({ fs, exec }) => { await exec('npx @tailwindcss/upgrade') - await fs.expectFileToContain( - 'src/index.css', - css` - .a { - @apply flex; - } + expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` + " + --- ./src/index.css --- + @import 'tailwindcss'; - .b { - @apply flex!; - } + .a { + @apply flex; + } - .c { - @apply flex! flex-col! items-center!; - } - `, - ) + .b { + @apply flex!; + } + + .c { + @apply flex! flex-col! items-center!; + }" + `) }, ) @@ -191,27 +188,23 @@ 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` - @layer base { - html { - color: #333; - } + expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` + " + --- ./src/index.css --- + @import 'tailwindcss'; + + @layer base { + html { + color: #333; } - `, - ) - await fs.expectFileToContain( - 'src/index.css', - css` - @layer components { - .btn { - color: red; - } + } + + @layer components { + .btn { + color: red; } - `, - ) + }" + `) }, ) @@ -253,22 +246,23 @@ test( async ({ fs, exec }) => { await exec('npx @tailwindcss/upgrade') - await fs.expectFileToContain( - 'src/index.css', - css` - @utility btn { - @apply rounded-md px-2 py-1 bg-blue-500 text-white; - } + expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` + " + --- ./src/index.css --- + @import 'tailwindcss'; - @utility no-scrollbar { - &::-webkit-scrollbar { - display: none; - } - -ms-overflow-style: none; - scrollbar-width: none; + @utility btn { + @apply rounded-md px-2 py-1 bg-blue-500 text-white; + } + + @utility no-scrollbar { + &::-webkit-scrollbar { + display: none; } - `, - ) + -ms-overflow-style: none; + scrollbar-width: none; + }" + `) }, ) @@ -533,12 +527,14 @@ test( async ({ exec, fs }) => { await exec('npx @tailwindcss/upgrade') - await fs.expectFileToContain('src/index.html', html` -
- `) - await fs.expectFileToContain('src/other.html', html` -
- `) + expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` + " + --- ./src/index.html --- +
+ + --- ./src/other.html --- +
" + `) }, ) @@ -571,18 +567,309 @@ test( async ({ exec, fs }) => { await exec('npx @tailwindcss/upgrade') - await fs.expectFileToContain( - 'src/index.html', - html` -
+ expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` + " + --- ./src/index.html --- +
+ + --- ./src/other.html --- +
" + `) + }, +) + +test( + 'migrate utilities in an imported file', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } `, - ) + 'tailwind.config.js': js`module.exports = {}`, + 'src/index.css': css` + @import 'tailwindcss'; + @import './utilities.css' layer(utilities); + `, + 'src/utilities.css': css` + .no-scrollbar::-webkit-scrollbar { + display: none; + } - await fs.expectFileToContain( - 'src/other.html', - html` -
+ .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade --force') + + expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` + " + --- ./src/index.css --- + @import 'tailwindcss'; + @import './utilities.css'; + + --- ./src/utilities.css --- + @utility no-scrollbar { + &::-webkit-scrollbar { + display: none; + } + -ms-overflow-style: none; + scrollbar-width: none; + }" + `) + }, +) + +test( + 'migrate utilities in deep import trees', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.js': js`module.exports = {}`, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css' layer(utilities); + @import './b.1.css' layer(components); + @import './c.1.css'; + @import './d.1.css'; + `, + 'src/a.1.css': css` + @import './a.1.utilities.css'; + + .foo-from-a { + color: red; + } + `, + 'src/a.1.utilities.css': css` + #foo { + --keep: me; + } + + .foo-from-import { + color: blue; + } + `, + 'src/b.1.css': css` + @import './b.1.components.css'; + + .bar-from-b { + color: red; + } + `, + 'src/b.1.components.css': css` + .bar-from-import { + color: blue; + } + `, + 'src/c.1.css': css` + @import './c.2.css' layer(utilities); + .baz-from-c { + color: green; + } + `, + 'src/c.2.css': css` + @import './c.3.css'; + #baz { + --keep: me; + } + .baz-from-import { + color: yellow; + } + `, + 'src/c.3.css': css` + #baz { + --keep: me; + } + .baz-from-import { + color: yellow; + } + `, + + // This is a super deep import chain + // And no `*.utilities.css` files should be created for these + // because there are no rules that need to be separated + 'src/d.1.css': css`@import './d.2.css' layer(utilities);`, + 'src/d.2.css': css`@import './d.3.css';`, + 'src/d.3.css': css`@import './d.4.css';`, + 'src/d.4.css': css` + .from-a-4 { + color: blue; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade --force') + + expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` + " + --- ./src/index.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities); + @import './a.1.utilities.1.css'; + @import './b.1.css'; + @import './c.1.css' layer(utilities); + @import './c.1.utilities.css'; + @import './d.1.css'; + + --- ./src/a.1.css --- + @import './a.1.utilities.css' + + --- ./src/a.1.utilities.1.css --- + @import './a.1.utilities.utilities.css'; + @utility foo-from-a { + color: red; + } + + --- ./src/a.1.utilities.css --- + #foo { + --keep: me; + } + + --- ./src/a.1.utilities.utilities.css --- + @utility foo-from-import { + color: blue; + } + + --- ./src/b.1.components.css --- + @utility bar-from-import { + color: blue; + } + + --- ./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; + } + + --- ./src/c.1.utilities.css --- + @import './c.2.utilities.css' + + --- ./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; + } + + --- ./src/c.3.css --- + #baz { + --keep: me; + } + + --- ./src/c.3.utilities.css --- + @utility baz-from-import { + color: yellow; + } + + --- ./src/d.1.css --- + @import './d.2.css' + + --- ./src/d.2.css --- + @import './d.3.css' + + --- ./src/d.3.css --- + @import './d.4.css' + + --- ./src/d.4.css --- + @utility from-a-4 { + color: blue; + }" + `) + }, +) + +test( + 'migrate utility files imported by multiple roots', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.js': js`module.exports = {}`, + 'src/index.html': html` +
+ `, + 'src/root.1.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css' layer(utilities); + `, + 'src/root.2.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css' layer(components); + `, + 'src/root.3.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css'; + `, + 'src/a.1.css': css` + .foo-from-a { + color: red; + } + `, + }, + }, + async ({ fs, exec }) => { + let output = await exec('npx @tailwindcss/upgrade --force') + + expect(output).toMatch( + /You have one or more stylesheets that are imported into a utility layer and non-utility layer./, ) + + expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` + " + --- ./src/a.1.css --- + .foo-from-a { + color: red; + } + + --- ./src/root.1.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities); + + --- ./src/root.2.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(components); + + --- ./src/root.3.css --- + @import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities);" + `) }, ) diff --git a/integrations/utils.ts b/integrations/utils.ts index fcea2a0ff235..ce700bce7f7a 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -41,6 +41,7 @@ interface TestContext { write(filePath: string, content: string): Promise read(filePath: string): Promise glob(pattern: string): Promise<[string, string][]> + dumpFiles(pattern: string): Promise expectFileToContain( filePath: string, contents: string | string[] | RegExp | RegExp[], @@ -113,7 +114,7 @@ export function test( if (execOptions.ignoreStdErr !== true) console.error(stderr) reject(error) } else { - resolve(stdout.toString()) + resolve(stdout.toString() + '\n\n' + stderr.toString()) } }, ) @@ -306,6 +307,31 @@ export function test( }), ) }, + async dumpFiles(pattern: string) { + let files = await context.fs.glob(pattern) + return `\n${files + .slice() + .sort((a: [string], z: [string]) => { + let aParts = a[0].split('/') + let zParts = z[0].split('/') + + let aFile = aParts.at(-1) + let zFile = aParts.at(-1) + + // Sort by depth, shallow first + if (aParts.length < zParts.length) return -1 + if (aParts.length > zParts.length) return 1 + + // Sort by filename, sort files named `index` before others + if (aFile?.startsWith('index')) return -1 + if (zFile?.startsWith('index')) return 1 + + // Sort by filename, alphabetically + return a[0].localeCompare(z[0]) + }) + .map(([file, content]) => `--- ${file} ---\n${content || ''}`) + .join('\n\n')}` + }, async expectFileToContain(filePath, contents) { return retryAssertion(async () => { let fileContent = await this.read(filePath) 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 f53e3f3c9a48..cbf96d58f041 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,16 +1,40 @@ import dedent from 'dedent' import postcss from 'postcss' import { describe, expect, it } from 'vitest' +import { Stylesheet } from '../stylesheet' import { formatNodes } from './format-nodes' import { migrateAtLayerUtilities } from './migrate-at-layer-utilities' const css = dedent -function migrate(input: string) { +async function migrate( + data: + | string + | { + root: postcss.Root + layers?: string[] + }, +) { + let stylesheet: Stylesheet + + if (typeof data === 'string') { + stylesheet = await Stylesheet.fromString(data) + } else { + stylesheet = await Stylesheet.fromRoot(data.root) + + if (data.layers) { + let meta = { layers: data.layers } + let parent = await Stylesheet.fromString('.placeholder {}') + + stylesheet.parents.add({ item: parent, meta }) + parent.children.add({ item: stylesheet, meta }) + } + } + return postcss() - .use(migrateAtLayerUtilities()) + .use(migrateAtLayerUtilities(stylesheet)) .use(formatNodes()) - .process(input, { from: expect.getState().testPath }) + .process(stylesheet.root!, { from: expect.getState().testPath }) .then((result) => result.css) } @@ -820,3 +844,213 @@ it('should not lose attribute selectors', async () => { }" `) }) + +describe('layered stylesheets', () => { + it('should transform classes to utilities inside a layered stylesheet (utilities)', async () => { + expect( + await migrate({ + root: postcss.parse(css` + /* Utility #1 */ + .foo { + /* Declarations: */ + color: red; + } + `), + layers: ['utilities'], + }), + ).toMatchInlineSnapshot(` + "@utility foo { + /* Utility #1 */ + /* Declarations: */ + color: red; + }" + `) + }) + + it('should transform classes to utilities inside a layered stylesheet (components)', async () => { + expect( + await migrate({ + root: postcss.parse(css` + /* Utility #1 */ + .foo { + /* Declarations: */ + color: red; + } + `), + layers: ['components'], + }), + ).toMatchInlineSnapshot(` + "@utility foo { + /* Utility #1 */ + /* Declarations: */ + color: red; + }" + `) + }) + + it('should NOT transform classes to utilities inside a non-utility, layered stylesheet', async () => { + expect( + await migrate({ + root: postcss.parse(css` + /* Utility #1 */ + .foo { + /* Declarations: */ + color: red; + } + `), + layers: ['foo'], + }), + ).toMatchInlineSnapshot(` + "/* Utility #1 */ + .foo { + /* Declarations: */ + color: red; + }" + `) + }) + + it('should handle non-classes in utility-layered stylesheets', async () => { + expect( + await migrate({ + root: postcss.parse(css` + /* Utility #1 */ + .foo { + /* Declarations: */ + color: red; + } + #main { + color: red; + } + `), + layers: ['utilities'], + }), + ).toMatchInlineSnapshot(` + " + #main { + color: red; + } + + @utility foo { + /* Utility #1 */ + /* Declarations: */ + color: red; + }" + `) + }) + + it('should handle non-classes in utility-layered stylesheets', async () => { + expect( + await migrate({ + root: postcss.parse(css` + @layer utilities { + @layer utilities { + /* Utility #1 */ + .foo { + /* Declarations: */ + color: red; + } + } + + /* Utility #2 */ + .bar { + /* Declarations: */ + color: red; + } + + #main { + color: red; + } + } + + /* Utility #3 */ + .baz { + /* Declarations: */ + color: red; + } + + #secondary { + color: red; + } + `), + layers: ['utilities'], + }), + ).toMatchInlineSnapshot(` + "@layer utilities { + + #main { + color: red; + } + } + + #secondary { + color: red; + } + + @utility foo { + @layer utilities { + @layer utilities { + /* Utility #1 */ + /* Declarations: */ + color: red; + } + } + } + + @utility bar { + @layer utilities { + /* Utility #2 */ + /* Declarations: */ + color: red; + } + } + + @utility baz { + /* Utility #3 */ + /* Declarations: */ + color: red; + }" + `) + }) + + it('imports are preserved in layered stylesheets', async () => { + expect( + await migrate({ + root: postcss.parse(css` + @import 'thing'; + + .foo { + color: red; + } + `), + layers: ['utilities'], + }), + ).toMatchInlineSnapshot(` + "@import 'thing'; + + @utility foo { + color: red; + }" + `) + }) + + it('charset is preserved in layered stylesheets', async () => { + expect( + await migrate({ + root: postcss.parse(css` + @charset "utf-8"; + + .foo { + color: red; + } + `), + layers: ['utilities'], + }), + ).toMatchInlineSnapshot(` + "@charset "utf-8"; + + @utility foo { + 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 cedef0545a68..e274dc202155 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts @@ -1,9 +1,10 @@ import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss' import SelectorParser from 'postcss-selector-parser' import { segment } from '../../../tailwindcss/src/utils/segment' +import { Stylesheet } from '../stylesheet' import { walk, WalkAction, walkDepth } from '../utils/walk' -export function migrateAtLayerUtilities(): Plugin { +export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin { function migrate(atRule: AtRule) { // Only migrate `@layer utilities` and `@layer components`. if (atRule.params !== 'utilities' && atRule.params !== 'components') return @@ -86,6 +87,12 @@ export function migrateAtLayerUtilities(): Plugin { clones.push(clone) walk(clone, (node) => { + if (node.type === 'atrule') { + if (!node.nodes || node.nodes?.length === 0) { + node.remove() + } + } + if (node.type !== 'rule') return // Fan out each utility into its own rule. @@ -186,7 +193,7 @@ export function migrateAtLayerUtilities(): Plugin { // Mark the node as pretty so that it gets formatted by Prettier later. clone.raws.tailwind_pretty = true - clone.raws.before += '\n\n' + clone.raws.before = `${clone.raws.before ?? ''}\n\n` } // Cleanup @@ -259,7 +266,16 @@ export function migrateAtLayerUtilities(): Plugin { return { postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities', - OnceExit: (root) => { + OnceExit: (root, { atRule }) => { + let layers = stylesheet.layers() + let isUtilityStylesheet = layers.has('utilities') || layers.has('components') + + if (isUtilityStylesheet) { + let rule = atRule({ name: 'layer', params: 'utilities' }) + rule.append(root.nodes) + root.append(rule) + } + // 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. @@ -282,6 +298,17 @@ export function migrateAtLayerUtilities(): Plugin { } }) } + + // If the stylesheet is inside a layered import then we can remove the top-level layer directive we added + if (isUtilityStylesheet) { + root.each((node) => { + if (node.type !== 'atrule') return + if (node.name !== 'layer') return + if (node.params !== 'utilities') return + + node.replaceWith(node.nodes ?? []) + }) + } }, } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts index c32c609063db..6d3de9fe6c33 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-missing-layers.ts @@ -71,6 +71,7 @@ export function migrateMissingLayers(): Plugin { if (node.name === 'import') { if (lastLayer !== '' && !node.params.includes('layer(')) { node.params += ` layer(${lastLayer})` + node.raws.tailwind_injected_layer = true } if (bucket.length > 0) { @@ -110,7 +111,7 @@ export function migrateMissingLayers(): Plugin { let target = nodes[0] let layerNode = new AtRule({ name: 'layer', - params: layerName || firstLayerName || '', + params: targetLayerName, nodes: nodes.map((node) => { // Keep the target node as-is, because we will be replacing that one // with the new layer node. diff --git a/packages/@tailwindcss-upgrade/src/index.test.ts b/packages/@tailwindcss-upgrade/src/index.test.ts index 581b9e3d6d9c..fb5457774f88 100644 --- a/packages/@tailwindcss-upgrade/src/index.test.ts +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -1,6 +1,8 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import dedent from 'dedent' +import postcss from 'postcss' import { expect, it } from 'vitest' +import { formatNodes } from './codemods/format-nodes' import { migrateContents } from './migrate' const css = dedent @@ -13,9 +15,15 @@ let designSystem = await __unstable__loadDesignSystem( ) let config = { designSystem, userConfig: {}, newPrefix: null } +function migrate(input: string, config: any) { + return migrateContents(input, config, expect.getState().testPath) + .then((result) => postcss([formatNodes()]).process(result.root, result.opts)) + .then((result) => result.css) +} + it('should print the input as-is', async () => { expect( - await migrateContents( + await migrate( css` /* above */ .foo/* after */ { @@ -25,7 +33,6 @@ it('should print the input as-is', async () => { } `, config, - expect.getState().testPath, ), ).toMatchInlineSnapshot(` "/* above */ @@ -39,7 +46,7 @@ it('should print the input as-is', async () => { it('should migrate a stylesheet', async () => { expect( - await migrateContents( + await migrate( css` @tailwind base; @@ -116,7 +123,7 @@ it('should migrate a stylesheet', async () => { it('should migrate a stylesheet (with imports)', async () => { expect( - await migrateContents( + await migrate( css` @import 'tailwindcss/base'; @import './my-base.css'; @@ -137,7 +144,7 @@ it('should migrate a stylesheet (with imports)', async () => { it('should migrate a stylesheet (with preceding rules that should be wrapped in an `@layer`)', async () => { expect( - await migrateContents( + await migrate( css` @charset "UTF-8"; @layer foo, bar, baz; @@ -166,7 +173,7 @@ it('should migrate a stylesheet (with preceding rules that should be wrapped in it('should keep CSS as-is before existing `@layer` at-rules', async () => { expect( - await migrateContents( + await migrate( css` .foo { color: blue; diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index eeae17edf8d0..6bf078df4962 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,10 +1,18 @@ #!/usr/bin/env node import { globby } from 'globby' +import fs from 'node:fs/promises' import path from 'node:path' +import postcss from 'postcss' +import { formatNodes } from './codemods/format-nodes' import { help } from './commands/help' -import { migrate as migrateStylesheet } from './migrate' +import { + analyze as analyzeStylesheets, + migrate as migrateStylesheet, + split as splitStylesheets, +} from './migrate' import { migratePostCSSConfig } from './migrate-postcss' +import { Stylesheet } from './stylesheet' import { migrate as migrateTemplate } from './template/migrate' import { prepareConfig } from './template/prepare-config' import { args, type Arg } from './utils/args' @@ -94,8 +102,56 @@ async function run() { // Ensure we are only dealing with CSS files files = files.filter((file) => file.endsWith('.css')) + // Analyze the stylesheets + let loadResults = await Promise.allSettled(files.map((filepath) => Stylesheet.load(filepath))) + + // Load and parse all stylesheets + for (let result of loadResults) { + if (result.status === 'rejected') { + error(`${result.reason}`) + } + } + + let stylesheets = loadResults + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value) + + // Analyze the stylesheets + try { + await analyzeStylesheets(stylesheets) + } catch (e: unknown) { + error(`${e}`) + } + // Migrate each file - await Promise.allSettled(files.map((file) => migrateStylesheet(file, config))) + let migrateResults = await Promise.allSettled( + stylesheets.map((sheet) => migrateStylesheet(sheet, config)), + ) + + for (let result of migrateResults) { + if (result.status === 'rejected') { + error(`${result.reason}`) + } + } + + // Split up stylesheets (as needed) + try { + await splitStylesheets(stylesheets) + } catch (e: unknown) { + error(`${e}`) + } + + // Format nodes + for (let sheet of stylesheets) { + await postcss([formatNodes()]).process(sheet.root!, { from: sheet.file! }) + } + + // Write all files to disk + for (let sheet of stylesheets) { + if (!sheet.file) continue + + await fs.writeFile(sheet.file, sheet.root.toString()) + } success('Stylesheet migration complete.') } diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index a47289b09f0e..0b32cace52c0 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -1,14 +1,17 @@ -import fs from 'node:fs/promises' import path from 'node:path' import postcss from 'postcss' import type { Config } from 'tailwindcss' import type { DesignSystem } from '../../tailwindcss/src/design-system' -import { formatNodes } from './codemods/format-nodes' +import { DefaultMap } from '../../tailwindcss/src/utils/default-map' +import { segment } from '../../tailwindcss/src/utils/segment' import { migrateAtApply } from './codemods/migrate-at-apply' import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities' import { migrateMediaScreen } from './codemods/migrate-media-screen' import { migrateMissingLayers } from './codemods/migrate-missing-layers' import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives' +import { Stylesheet, type StylesheetConnection, type StylesheetId } from './stylesheet' +import { resolveCssId } from './utils/resolve' +import { walk, WalkAction } from './utils/walk' export interface MigrateOptions { newPrefix: string | null @@ -16,21 +19,391 @@ export interface MigrateOptions { userConfig: Config } -export async function migrateContents(contents: string, options: MigrateOptions, file?: string) { +export async function migrateContents( + stylesheet: Stylesheet | string, + options: MigrateOptions, + file?: string, +) { + if (typeof stylesheet === 'string') { + stylesheet = await Stylesheet.fromString(stylesheet) + stylesheet.file = file ?? null + } + return postcss() .use(migrateAtApply(options)) .use(migrateMediaScreen(options)) - .use(migrateAtLayerUtilities()) + .use(migrateAtLayerUtilities(stylesheet)) .use(migrateMissingLayers()) .use(migrateTailwindDirectives(options)) - .use(formatNodes()) - .process(contents, { from: file }) - .then((result) => result.css) + .process(stylesheet.root, { from: stylesheet.file ?? undefined }) +} + +export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { + if (!stylesheet.file) { + throw new Error('Cannot migrate a stylesheet without a file path') + } + + if (!stylesheet.canMigrate) return + + await migrateContents(stylesheet, options) } -export async function migrate(file: string, options: MigrateOptions) { - let fullPath = path.resolve(process.cwd(), file) - let contents = await fs.readFile(fullPath, 'utf-8') +export async function analyze(stylesheets: Stylesheet[]) { + let stylesheetsByFile = new Map() + + for (let sheet of stylesheets) { + if (sheet.file) { + stylesheetsByFile.set(sheet.file, sheet) + } + } + + // Step 1: Record which `@import` rules point to which stylesheets + // and which stylesheets are parents/children of each other + let processor = postcss([ + { + postcssPlugin: 'mark-import-nodes', + AtRule: { + import(node) { + // Find what the import points to + let id = node.params.match(/['"](.*)['"]/)?.[1] + if (!id) return + + let basePath = node.source?.input.file + ? path.dirname(node.source.input.file) + : process.cwd() + + // Resolve the import to a file path + let resolvedPath: string | false + try { + resolvedPath = resolveCssId(id, basePath) + } catch (err) { + console.warn(`Failed to resolve import: ${id}. Skipping.`) + console.error(err) + return + } + + if (!resolvedPath) return + + // Find the stylesheet pointing to the resolved path + let stylesheet = stylesheetsByFile.get(resolvedPath) + + // If it _does not_ exist in stylesheets we don't care and skip it + // this is likely because its in node_modules or a workspace package + // that we don't want to modify + if (!stylesheet) return + + // Mark the import node with the ID of the stylesheet it points to + // We will use these later to build lookup tables and modify the AST + node.raws.tailwind_destination_sheet_id = stylesheet.id + + let parent = node.source?.input.file + ? stylesheetsByFile.get(node.source.input.file) + : undefined + + let layers: string[] = [] + + for (let part of segment(node.params, ' ')) { + if (!part.startsWith('layer(')) continue + if (!part.endsWith(')')) continue + + layers.push(part.slice(6, -1).trim()) + } + + // Connect sheets together in a dependency graph + if (parent) { + let meta = { layers } + stylesheet.parents.add({ item: parent, meta }) + parent.children.add({ item: stylesheet, meta }) + } + }, + }, + }, + ]) + + for (let sheet of stylesheets) { + if (!sheet.file) continue + + await processor.process(sheet.root, { from: sheet.file }) + } + + let commonPath = process.cwd() + + function pathToString(path: StylesheetConnection[]) { + let parts: string[] = [] + + for (let connection of path) { + if (!connection.item.file) continue + + let filePath = connection.item.file.replace(commonPath, '') + let layers = connection.meta.layers.join(', ') + + if (layers.length > 0) { + parts.push(`${filePath} (layers: ${layers})`) + } else { + parts.push(filePath) + } + } + + return parts.join(' <- ') + } + + let lines: string[] = [] + + for (let sheet of stylesheets) { + if (!sheet.file) continue + + let { convertablePaths, nonConvertablePaths } = sheet.analyzeImportPaths() + let isAmbiguous = convertablePaths.length > 0 && nonConvertablePaths.length > 0 + + if (!isAmbiguous) continue + + sheet.canMigrate = false + + let filePath = sheet.file.replace(commonPath, '') + + for (let path of convertablePaths) { + lines.push(`- ${filePath} <- ${pathToString(path)}`) + } + + for (let path of nonConvertablePaths) { + lines.push(`- ${filePath} <- ${pathToString(path)}`) + } + } + + if (lines.length === 0) return + + let error = `You have one or more stylesheets that are imported into a utility layer and non-utility layer.\n` + error += `We cannot convert stylesheets under these conditions. Please look at the following stylesheets:\n` + + throw new Error(error + lines.join('\n')) +} + +export async function split(stylesheets: Stylesheet[]) { + let stylesheetsById = new Map() + let stylesheetsByFile = new Map() + + for (let sheet of stylesheets) { + stylesheetsById.set(sheet.id, sheet) + + if (sheet.file) { + stylesheetsByFile.set(sheet.file, sheet) + } + } + + // Keep track of sheets that contain `@utillity` rules + let containsUtilities = new Set() + + for (let sheet of stylesheets) { + let layers = sheet.layers() + let isLayered = layers.has('utilities') || layers.has('components') + if (!isLayered) continue + + walk(sheet.root, (node) => { + if (node.type !== 'atrule') return + if (node.name !== 'utility') return + + containsUtilities.add(sheet) + + return WalkAction.Stop + }) + } + + // Split every imported stylesheet into two parts + let utilitySheets = new Map() + + for (let sheet of stylesheets) { + // Ignore stylesheets that were not imported + if (!sheet.file) continue + if (sheet.parents.size === 0) continue + + // Skip stylesheets that don't have utilities + // and don't have any children that have utilities + if (!containsUtilities.has(sheet)) { + if (!Array.from(sheet.descendants()).some((child) => containsUtilities.has(child))) { + continue + } + } + + let utilities = postcss.root({ + raws: { + tailwind_pretty: true, + }, + }) + + walk(sheet.root, (node) => { + if (node.type !== 'atrule') return + if (node.name !== 'utility') return + + // `append` will move this node from the original sheet + // to the new utilities sheet + utilities.append(node) + + return WalkAction.Skip + }) + + let newFileName = sheet.file.replace(/\.css$/, '.utilities.css') + + let counter = 0 + + // If we already have a utility sheet with this name, we need to rename it + while (stylesheetsByFile.has(newFileName)) { + counter += 1 + newFileName = sheet.file.replace(/\.css$/, `.utilities.${counter}.css`) + } + + let utilitySheet = await Stylesheet.fromRoot(utilities, newFileName) + + utilitySheet.extension = counter > 0 ? `.utilities.${counter}.css` : `.utilities.css` + + utilitySheets.set(sheet, utilitySheet) + stylesheetsById.set(utilitySheet.id, utilitySheet) + } + + // Make sure the utility sheets are linked to one another + for (let [normalSheet, utilitySheet] of utilitySheets) { + for (let parent of normalSheet.parents) { + let utilityParent = utilitySheets.get(parent.item) + if (!utilityParent) continue + utilitySheet.parents.add({ + item: utilityParent, + meta: parent.meta, + }) + } + + for (let child of normalSheet.children) { + let utilityChild = utilitySheets.get(child.item) + if (!utilityChild) continue + utilitySheet.children.add({ + item: utilityChild, + meta: child.meta, + }) + } + } + + for (let sheet of stylesheets) { + let utilitySheet = utilitySheets.get(sheet) + let utilityImports: Set = new Set() + + for (let node of sheet.importRules) { + let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined + + // This import rule does not point to a stylesheet + // which likely means it points to `node_modules` + if (!sheetId) continue + + let originalDestination = stylesheetsById.get(sheetId) + + // This import points to a stylesheet that no longer exists which likely + // means it was removed by the optimizer this will be cleaned up later + if (!originalDestination) continue + + let utilityDestination = utilitySheets.get(originalDestination) + + // A utility sheet doesn't exist for this import so it doesn't need + // to be processed + if (!utilityDestination) continue + + let match = node.params.match(/(['"])(.*)\1/) + if (!match) return + + let quote = match[1] + let id = match[2] + + let newFile = id.replace(/\.css$/, utilityDestination.extension!) + + // The import will just point to the new file without any media queries, + // layers, or other conditions because `@utility` MUST be top-level. + let newImport = node.clone({ + params: `${quote}${newFile}${quote}`, + raws: { + after: '\n\n', + tailwind_original_params: `${quote}${id}${quote}`, + tailwind_destination_sheet_id: utilityDestination.id, + }, + }) + + if (utilitySheet) { + // If this import is intended to go into the utility sheet + // we'll collect it into a list to add later. If we don't' + // we'll end up adding them in reverse order. + utilityImports.add(newImport) + } else { + // This import will go immediately after the original import + node.after(newImport) + } + } + + // Add imports to the top of the utility sheet if necessary + if (utilitySheet && utilityImports.size > 0) { + utilitySheet.root.prepend(Array.from(utilityImports)) + } + } + + // Tracks the at rules that import a given stylesheet + let importNodes = new DefaultMap>(() => new Set()) + + for (let sheet of stylesheetsById.values()) { + for (let node of sheet.importRules) { + let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined + + // This import rule does not point to a stylesheet + if (!sheetId) continue + + let destination = stylesheetsById.get(sheetId) + + // This import rule does not point to a stylesheet that exists + // We'll remove it later + if (!destination) continue + + importNodes.get(destination).add(node) + } + } + + // At this point we've created many `{name}.utilities.css` files. + // If the original file _becomes_ empty after splitting that means that + // dedicated utility file is not required and we can move the utilities + // back to the original file. + // + // This could be done in one step but separating them makes it easier to + // reason about since the stylesheets are in a consistent state before we + // perform any cleanup tasks. + let list: Stylesheet[] = [] + + for (let sheet of stylesheets.slice()) { + for (let child of sheet.descendants()) { + list.push(child) + } + + list.push(sheet) + } + + for (let sheet of list) { + let utilitySheet = utilitySheets.get(sheet) + + // This sheet was not split so there's nothing to do + if (!utilitySheet) continue + + // This sheet did not become empty + if (!sheet.isEmpty) continue + + // We have a sheet that became empty after splitting + // 1. Replace the sheet with it's utility sheet content + sheet.root = utilitySheet.root + + // 2. Rewrite imports in parent sheets to point to the original sheet + // Ideally this wouldn't need to be _undone_ but instead only done once at the end + for (let node of importNodes.get(utilitySheet)) { + node.params = node.raws.tailwind_original_params as any + } + + // 3. Remove the original import from the non-utility sheet + for (let node of importNodes.get(sheet)) { + node.remove() + } + + // 3. Mark the utility sheet for removal + utilitySheets.delete(sheet) + } - await fs.writeFile(fullPath, await migrateContents(contents, options, fullPath)) + stylesheets.push(...utilitySheets.values()) } diff --git a/packages/@tailwindcss-upgrade/src/stylesheet.ts b/packages/@tailwindcss-upgrade/src/stylesheet.ts new file mode 100644 index 000000000000..c3a91fe4ba88 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/stylesheet.ts @@ -0,0 +1,249 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as util from 'node:util' +import * as postcss from 'postcss' + +export type StylesheetId = string + +export interface StylesheetConnection { + item: Stylesheet + meta: { + layers: string[] + } +} + +export class Stylesheet { + /** + * A unique identifier for this stylesheet + * + * Used to track the stylesheet in PostCSS nodes. + */ + id: StylesheetId + + /** + * The PostCSS AST that represents this stylesheet. + */ + root: postcss.Root + + /** + * The path to the file that this stylesheet was loaded from. + * + * If this stylesheet was not loaded from a file this will be `null`. + */ + file: string | null = null + + /** + * Stylesheets that import this stylesheet. + */ + parents = new Set() + + /** + * Stylesheets that are imported by stylesheet. + */ + children = new Set() + + /** + * Whether or not this stylesheet can be migrated + */ + canMigrate = true + + /** + * Whether or not this stylesheet can be migrated + */ + extension: string | null = null + + static async load(filepath: string) { + filepath = path.resolve(process.cwd(), filepath) + + let css = await fs.readFile(filepath, 'utf-8') + let root = postcss.parse(css, { from: filepath }) + + return new Stylesheet(root, filepath) + } + + static async fromString(css: string) { + let root = postcss.parse(css) + + return new Stylesheet(root) + } + + static async fromRoot(root: postcss.Root, file?: string) { + return new Stylesheet(root, file) + } + + constructor(root: postcss.Root, file?: string) { + this.id = Math.random().toString(36).slice(2) + this.root = root + this.file = file ?? null + + if (file) { + this.extension = path.extname(file) + } + } + + get importRules() { + let imports = new Set() + + this.root.walkAtRules('import', (rule) => { + imports.add(rule) + }) + + return imports + } + + get isEmpty() { + return this.root.toString().trim() === '' + } + + *ancestors() { + for (let { item } of walkDepth(this, (sheet) => sheet.parents)) { + yield item + } + } + + *descendants() { + for (let { item } of walkDepth(this, (sheet) => sheet.children)) { + yield item + } + } + + /** + * Return the layers the stylesheet is imported into directly or indirectly + */ + layers() { + let layers = new Set() + + for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) { + if (item.parents.size > 0) { + continue + } + + for (let { meta } of path) { + for (let layer of meta.layers) { + layers.add(layer) + } + } + } + + return layers + } + + /** + * Iterate all paths from a stylesheet through its ancestors to all roots + * + * For example, given the following structure: + * + * ``` + * c.css + * -> a.1.css @import "…" + * -> a.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * -> b.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * + * -> a.2.css @import "…" layer(foo) + * -> a.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * -> b.css + * -> root.1.css (utility: no) + * -> root.2.css (utility: no) + * + * -> b.1.css @import "…" layer(components / utilities) + * -> a.css + * -> root.1.css (utility: yes) + * -> root.2.css (utility: yes) + * -> b.css + * -> root.1.css (utility: yes) + * -> root.2.css (utility: yes) + * ``` + * + * We can see there are a total of 12 import paths with various layers. + * We need to be able to iterate every one of these paths and inspect + * the layers used in each path.. + */ + *pathsToRoot(): Iterable { + for (let { item, path } of walkDepth(this, (sheet) => sheet.parents)) { + // Skip over intermediate stylesheets since all paths from a leaf to a + // root will encompass all possible intermediate stylesheet paths. + if (item.parents.size > 0) { + continue + } + + yield path + } + } + + /** + * Analyze a stylesheets import paths to see if some can be considered + * for conversion to utility rules and others can't. + * + * If a stylesheet is imported directly or indirectly and some imports are in + * a utility layer and some are not that means that we can't safely convert + * the rules in the stylesheet to `@utility`. Doing so would mean that we + * would need to replicate the stylesheet and change one to have `@utility` + * rules and leave the other as is. + * + * We can see, given the same structure from the `pathsToRoot` example, that + * `css.css` is imported into different layers: + * - `a.1.css` has no layers and should not be converted + * - `a.2.css` has a layer `foo` and should not be converted + * - `b.1.css` has a layer `utilities` (or `components`) which should be + * + * Since this means that `c.css` must both not be converted and converted + * we can't do this without replicating the stylesheet, any ancestors, and + * adjusting imports which is a non-trivial task. + */ + analyzeImportPaths() { + let convertablePaths: StylesheetConnection[][] = [] + let nonConvertablePaths: StylesheetConnection[][] = [] + + for (let path of this.pathsToRoot()) { + let isConvertable = false + + for (let { meta } of path) { + for (let layer of meta.layers) { + isConvertable ||= layer === 'utilities' || layer === 'components' + } + } + + if (isConvertable) { + convertablePaths.push(path) + } else { + nonConvertablePaths.push(path) + } + } + + return { convertablePaths, nonConvertablePaths } + } + + [util.inspect.custom]() { + return { + ...this, + root: this.root.toString(), + layers: Array.from(this.layers()), + parents: Array.from(this.parents, (s) => s.item.id), + children: Array.from(this.children, (s) => s.item.id), + parentsMeta: Array.from(this.parents, (s) => s.meta), + childrenMeta: Array.from(this.children, (s) => s.meta), + } + } +} + +function* walkDepth( + value: Stylesheet, + connections: (value: Stylesheet) => Iterable, + path: StylesheetConnection[] = [], +): Iterable<{ item: Stylesheet; path: StylesheetConnection[] }> { + for (let connection of connections(value)) { + let newPath = [...path, connection] + + yield* walkDepth(connection.item, connections, newPath) + yield { + item: connection.item, + path: newPath, + } + } +}