diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 881f40010..a833754e5 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -1,3 +1,4 @@ +import { expect } from 'vitest' import { css, html, js, json, test } from '../utils' test( @@ -261,3 +262,433 @@ test( ) }, ) + +test( + 'migrate utilities in an imported file', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/index.css': css` + @import 'tailwindcss'; + @import './utilities.css' layer(utilities); + `, + 'src/utilities.css': css` + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade --force') + + await fs.expectFileToContain( + 'src/index.css', + css` + @import 'tailwindcss'; + @import './utilities.css'; + `, + ) + + await fs.expectFileToContain( + 'src/utilities.css', + css` + @utility no-scrollbar { + &::-webkit-scrollbar { + display: none; + } + -ms-overflow-style: none; + scrollbar-width: none; + } + `, + ) + }, +) + +test( + 'wip it', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @import './a.css' layer(utilities); + @import './b.css' layer(components); + @import './c.css'; + `, + 'src/a.css': css` + @import './utilities.css'; + + .foo-from-a { + color: red; + } + `, + 'src/utilities.css': css` + #foo { + --keep: me; + } + + .foo-from-import { + color: blue; + } + `, + 'src/b.css': css` + @import './components.css'; + + .bar-from-b { + color: red; + } + `, + 'src/components.css': css` + .bar-from-import { + color: blue; + } + `, + 'src/c.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; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade --force') + + expect(await fs.read('src/index.css')).toMatchInlineSnapshot(` + "@import 'tailwindcss/utilities' layer(utilities); + @import './a.css' layer(utilities); + @import "./a.utilities.css"; + @import './b.css' layer(components); + @import "./b.utilities.css"; + @import './c.css' layer(utilities); + @import "./c.utilities.css";" + `) + expect(await fs.read('src/a.css')).toMatchInlineSnapshot(`"@import './utilities.css'"`) + expect(await fs.read('src/a.utilities.css')).toMatchInlineSnapshot(` + "@import "./utilities.utilities.css"; + @utility foo-from-a { + color: red; + }" + `) + + expect(await fs.read('src/utilities.css')).toMatchInlineSnapshot(` + "#foo { + --keep: me; + }" + `) + + expect(await fs.read('src/utilities.utilities.css')).toMatchInlineSnapshot(` + "@utility foo-from-import { + color: blue; + }" + `) + + expect(await fs.read('src/b.css')).toMatchInlineSnapshot(`""`) + expect(await fs.read('src/b.utilities.css')).toMatchInlineSnapshot(` + "@import "./components.css"; + @utility bar-from-b { + color: red; + }" + `) + + expect(await fs.read('src/c.css')).toMatchInlineSnapshot(` + "@import './c-2.css' layer(utilities); + .baz-from-c { + color: green; + }" + `) + expect(await fs.read('src/c.utilities.css')).toMatchInlineSnapshot( + `"@import "./c-2.utilities.css""`, + ) + + expect(await fs.read('src/c-2.css')).toMatchInlineSnapshot(` + "@import './c-3.css'; + #baz { + --keep: me; + }" + `) + expect(await fs.read('src/c-2.utilities.css')).toMatchInlineSnapshot(` + "@import "./c-3.utilities.css"; + @utility baz-from-import { + color: yellow; + }" + `) + + expect(await fs.read('src/c-3.css')).toMatchInlineSnapshot(` + "#baz { + --keep: me; + }" + `) + expect(await fs.read('src/c-3.utilities.css')).toMatchInlineSnapshot(` + "@utility baz-from-import { + color: yellow; + }" + `) + }, +) + +test( + 'deeply nested imports', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css' layer(utilities); + `, + 'src/a.1.css': css` + @import './a.2.css'; + + #from-a-1 { + --keep: me; + } + + .from-a-1 { + color: red; + } + `, + 'src/a.2.css': css` + @import './a.3.css'; + + #from-a-2 { + --keep: me; + } + + .from-a-2 { + color: green; + } + `, + 'src/a.3.css': css` + #from-a-3 { + --keep: me; + } + + .from-a-3 { + color: blue; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade --force') + + expect(await fs.read('src/index.css')).toMatchInlineSnapshot(` + "@import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities); + @import "./a.1.utilities.css";" + `) + expect(await fs.read('src/a.1.css')).toMatchInlineSnapshot(` + "@import './a.2.css'; + + #from-a-1 { + --keep: me; + }" + `) + expect(await fs.read('src/a.1.utilities.css')).toMatchInlineSnapshot(` + "@import "./a.2.utilities.css"; + @utility from-a-1 { + color: red; + }" + `) + expect(await fs.read('src/a.2.css')).toMatchInlineSnapshot(` + "@import './a.3.css'; + + #from-a-2 { + --keep: me; + }" + `) + expect(await fs.read('src/a.2.utilities.css')).toMatchInlineSnapshot(` + "@import "./a.3.utilities.css"; + @utility from-a-2 { + color: green; + }" + `) + expect(await fs.read('src/a.3.css')).toMatchInlineSnapshot(` + "#from-a-3 { + --keep: me; + }" + `) + expect(await fs.read('src/a.3.utilities.css')).toMatchInlineSnapshot(` + "@utility from-a-3 { + color: blue; + }" + `) + }, +) + +test( + 'deeply nested imports', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @import './a.1.css'; + `, + 'src/a.1.css': css` + @import './a.2.css' layer(utilities); + + #from-a-1 { + --keep: me; + } + + .from-a-1 { + color: red; + } + `, + 'src/a.2.css': css` + @import './a.3.css'; + + #from-a-2 { + --keep: me; + } + + .from-a-2 { + color: green; + } + `, + 'src/a.3.css': css` + @import './a.4.css'; + + #from-a-3 { + --keep: me; + } + + .from-a-3 { + color: blue; + } + `, + 'src/a.4.css': css` + #from-a-4 { + --keep: me; + } + + .from-a-4 { + color: blue; + } + `, + }, + }, + async ({ fs, exec }) => { + await exec('npx @tailwindcss/upgrade --force') + + expect(await fs.read('src/index.css')).toMatchInlineSnapshot(` + "@import 'tailwindcss/utilities' layer(utilities); + @import './a.1.css' layer(utilities); + @import "./a.1.utilities.css";" + `) + expect(await fs.read('src/a.1.css')).toMatchInlineSnapshot(` + "@import './a.2.css' layer(utilities); + + #from-a-1 { + --keep: me; + } + + .from-a-1 { + color: red; + }" + `) + expect(await fs.read('src/a.1.utilities.css')).toMatchInlineSnapshot( + `"@import "./a.2.utilities.css""`, + ) + expect(await fs.read('src/a.2.css')).toMatchInlineSnapshot(` + "@import './a.3.css'; + + #from-a-2 { + --keep: me; + }" + `) + expect(await fs.read('src/a.2.utilities.css')).toMatchInlineSnapshot(` + "@import "./a.3.utilities.css"; + @utility from-a-2 { + color: green; + }" + `) + expect(await fs.read('src/a.3.css')).toMatchInlineSnapshot(` + "@import './a.4.css'; + + #from-a-3 { + --keep: me; + }" + `) + expect(await fs.read('src/a.3.utilities.css')).toMatchInlineSnapshot(` + "@import "./a.4.utilities.css"; + @utility from-a-3 { + color: blue; + }" + `) + expect(await fs.read('src/a.4.css')).toMatchInlineSnapshot(` + "#from-a-4 { + --keep: me; + }" + `) + expect(await fs.read('src/a.4.utilities.css')).toMatchInlineSnapshot(` + "@utility from-a-4 { + color: blue; + }" + `) + }, +) 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 f53e3f3c9..2bac6ba6c 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,39 @@ 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: + | Stylesheet + | string + | { + root: postcss.Root + layers?: string[] + }, +) { + let stylesheet: Stylesheet + + if (typeof data === 'string') { + stylesheet = await Stylesheet.fromString(data) + } else if (data instanceof Stylesheet) { + stylesheet = data + } else { + stylesheet = await Stylesheet.fromRoot(data.root) + + for (let layer of data.layers ?? []) { + stylesheet.layers.add(layer) + } + } + 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 +843,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 cedef0545..c362a9fbd 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 isUtilityStylesheet = + stylesheet.layers?.has('utilities') || stylesheet.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 c32c60906..6d3de9fe6 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 4dbfaf46f..c6d817482 100644 --- a/packages/@tailwindcss-upgrade/src/index.test.ts +++ b/packages/@tailwindcss-upgrade/src/index.test.ts @@ -1,23 +1,27 @@ 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 +function migrate(input: string) { + return migrateContents(input) + .then((result) => postcss([formatNodes()]).process(result.root, result.opts)) + .then((result) => result.css) +} + it('should print the input as-is', async () => { expect( - await migrateContents( - css` + await migrate(css` + /* above */ + .foo/* after */ { /* above */ - .foo/* after */ { - /* above */ - color: /* before */ red /* after */; - /* below */ - } - `, - {}, - expect.getState().testPath, - ), + color: /* before */ red /* after */; + /* below */ + } + `), ).toMatchInlineSnapshot(` "/* above */ .foo/* after */ { @@ -30,44 +34,41 @@ it('should print the input as-is', async () => { it('should migrate a stylesheet', async () => { expect( - await migrateContents( - css` - @tailwind base; + await migrate(css` + @tailwind base; - html { - overflow: hidden; + html { + overflow: hidden; + } + + @tailwind components; + + .a { + z-index: 1; + } + + @layer components { + .b { + z-index: 2; } + } - @tailwind components; + .c { + z-index: 3; + } - .a { - z-index: 1; + @tailwind utilities; + + .d { + z-index: 4; + } + + @layer utilities { + .e { + z-index: 5; } - - @layer components { - .b { - z-index: 2; - } - } - - .c { - z-index: 3; - } - - @tailwind utilities; - - .d { - z-index: 4; - } - - @layer utilities { - .e { - z-index: 5; - } - } - `, - {}, - ), + } + `), ).toMatchInlineSnapshot(` "@import 'tailwindcss'; @@ -107,17 +108,14 @@ it('should migrate a stylesheet', async () => { it('should migrate a stylesheet (with imports)', async () => { expect( - await migrateContents( - css` - @import 'tailwindcss/base'; - @import './my-base.css'; - @import 'tailwindcss/components'; - @import './my-components.css'; - @import 'tailwindcss/utilities'; - @import './my-utilities.css'; - `, - {}, - ), + await migrate(css` + @import 'tailwindcss/base'; + @import './my-base.css'; + @import 'tailwindcss/components'; + @import './my-components.css'; + @import 'tailwindcss/utilities'; + @import './my-utilities.css'; + `), ).toMatchInlineSnapshot(` "@import 'tailwindcss'; @import './my-base.css' layer(base); @@ -128,20 +126,17 @@ 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( - css` - @charset "UTF-8"; - @layer foo, bar, baz; - /**! My license comment */ - html { - color: red; - } - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - {}, - ), + await migrate(css` + @charset "UTF-8"; + @layer foo, bar, baz; + /**! My license comment */ + html { + color: red; + } + @tailwind base; + @tailwind components; + @tailwind utilities; + `), ).toMatchInlineSnapshot(` "@charset "UTF-8"; @layer foo, bar, baz; @@ -157,20 +152,17 @@ 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( - css` - .foo { - color: blue; - } + await migrate(css` + .foo { + color: blue; + } - @layer components { - .bar { - color: red; - } + @layer components { + .bar { + color: red; } - `, - {}, - ), + } + `), ).toMatchInlineSnapshot(` ".foo { color: blue; diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 573c13f48..e85b55895 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,11 +1,20 @@ #!/usr/bin/env node import { globby } from 'globby' +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 { help } from './commands/help' -import { migrate as migrateStylesheet } from './migrate' +import { + analyze as analyzeStylesheets, + migrate as migrateStylesheet, + split as splitStylesheets, + type MigrateOptions, +} from './migrate' +import { Stylesheet } from './stylesheet' import { migrate as migrateTemplate } from './template/migrate' import { parseConfig } from './template/parseConfig' import { args, type Arg } from './utils/args' @@ -109,17 +118,63 @@ 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, { - newPrefix: parsedConfig?.newPrefix ?? undefined, - designSystem: parsedConfig?.designSystem, - userConfig: parsedConfig?.userConfig, - }), - ), + let options: MigrateOptions = { + newPrefix: parsedConfig?.newPrefix ?? undefined, + designSystem: parsedConfig?.designSystem, + userConfig: parsedConfig?.userConfig, + } + + let migrateResults = await Promise.allSettled( + stylesheets.map((sheet) => migrateStylesheet(sheet, options)), ) + 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 680e7c67b..b1d5b5f13 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -1,13 +1,16 @@ -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 { migrateMissingLayers } from './codemods/migrate-missing-layers' import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives' +import { Stylesheet, type StylesheetId } from './stylesheet' +import { resolveCssId } from './utils/resolve' +import { walk, WalkAction } from './utils/walk' export interface MigrateOptions { newPrefix?: string @@ -15,20 +18,342 @@ export interface MigrateOptions { userConfig?: Config } -export async function migrateContents(contents: string, options: MigrateOptions, file?: string) { +export async function migrateContents( + stylesheet: Stylesheet | string, + options: MigrateOptions = {}, +) { + if (typeof stylesheet === 'string') { + stylesheet = await Stylesheet.fromString(stylesheet) + } + return postcss() .use(migrateAtApply(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(file: string, options: MigrateOptions) { - let fullPath = path.resolve(process.cwd(), file) - let contents = await fs.readFile(fullPath, 'utf-8') +export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { + if (!stylesheet.file) { + throw new Error('Cannot migrate a stylesheet without a file path') + } - await fs.writeFile(fullPath, await migrateContents(contents, options, fullPath)) + await migrateContents(stylesheet, options) } + +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 + + // Connect sheets together in a dependency graph + if (parent) { + stylesheet.parents.add(parent) + parent.children.add(stylesheet) + } + + for (let part of segment(node.params, ' ')) { + if (!part.startsWith('layer(')) continue + if (!part.endsWith(')')) continue + + stylesheet.layers.add(part.slice(6, -1).trim()) + } + }, + }, + }, + ]) + + for (let sheet of stylesheets) { + if (!sheet.file) continue + + await processor.process(sheet.root, { from: sheet.file }) + } + + // Step 2: Analyze the AST so each stylesheet can know what layers it is inside + for (let sheet of stylesheets) { + for (let ancestor of sheet.ancestors) { + for (let layer of ancestor.layers) { + sheet.layers.add(layer) + } + } + } + + // Step 3: Determine which config files are used by each stylesheet (if any) + for (let sheet of stylesheets) { + for (let node of sheet.configRules) { + let configPath = node.params.match(/['"](.*)['"]/)?.[1] + if (!configPath) continue + + sheet.configFiles.add(configPath) + } + } + + for (let sheet of stylesheets) { + for (let parent of sheet.ancestors) { + for (let file of parent.configFiles) { + sheet.configFiles.add(file) + } + } + } +} + +export async function split(stylesheets: Stylesheet[]) { + let stylesheetsById = new Map() + for (let sheet of stylesheets) { + stylesheetsById.set(sheet.id, sheet) + } + + // A list of stylesheets we've created specifically to hold `@utility` rules + let containsUtilities = new Set() + + // Keep track of sheets that contain `@utillity` rules + for (let sheet of stylesheets) { + let isLayered = sheet.layers.has('utilities') || sheet.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 + // - one with the utilities and one without + 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 utilitySheet = await Stylesheet.fromRoot( + utilities, + sheet.file.replace(/\.css$/, '.utilities.css'), + ) + + utilitySheets.set(sheet, utilitySheet) + stylesheetsById.set(utilitySheet.id, utilitySheet) + } + + 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 id = node.params.match(/['"](.*)['"]/)?.[1] + if (!id) return + + let newFile = id.replace(/\.css$/, '.utilities.css') + + // 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: `"${newFile}"`, + raws: { + after: '\n\n', + 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)) + } + } + + // 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) + if (!utilityParent) continue + utilitySheet.parents.add(utilityParent) + } + + for (let child of normalSheet.children) { + let utilityChild = utilitySheets.get(child) + if (!utilityChild) continue + utilitySheet.children.add(utilityChild) + } + } + + // 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. + for (let sheet of stylesheets) { + 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 + for (let node of importNodes.get(utilitySheet)) { + node.params = node.params.replace(/\.utilities\.css(['"])/, '.css$1') + } + + // 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) + } + + stylesheets.push(...utilitySheets.values()) +} + +// @import './a.css' layer(utilities) ; +// -> @utility { … } + +// @import './a.css' layer(utilities); +// -> @import './b.css'; +// -> @import './c.css'; +// -> .utility-class +// -> #main +// -> other stuff +// -> other stuff + +// @import './a.css' layer(utilities); +// -> @import './b.css'; (layers: utilities) +// -> @import './c.css'; +// -> @import './d.css'; +// -> #main +// -> other stuff diff --git a/packages/@tailwindcss-upgrade/src/stylesheet.ts b/packages/@tailwindcss-upgrade/src/stylesheet.ts new file mode 100644 index 000000000..ce9833018 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/stylesheet.ts @@ -0,0 +1,124 @@ +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 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() + + /** + * The layers this stylesheet is in, even transitive layers from parents. + */ + layers = new Set() + + /** + * The list of config files that this stylesheet uses + */ + configFiles = new Set() + + 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 + } + + get importRules() { + let imports = new Set() + + this.root.walkAtRules('import', (rule) => { + imports.add(rule) + }) + + return imports + } + + get configRules() { + let rules = new Set() + + this.root.walkAtRules('config', (rule) => { + rules.add(rule) + }) + + return rules + } + + get isEmpty() { + return this.root.toString().trim() === '' + } + + get ancestors() { + return walk(this, (sheet) => sheet.parents ?? []) + } + + get descendants() { + return walk(this, (sheet) => sheet.children ?? []) + } + + [util.inspect.custom]() { + return { + ...this, + root: this.root.toString(), + layers: Array.from(this.layers), + parents: Array.from(this.parents, (s) => s.id), + children: Array.from(this.children, (s) => s.id), + } + } +} + +function* walk(value: T, getList: (value: T) => Iterable, seen = new Set()): Iterable { + for (let item of getList(value)) { + if (seen.has(item)) continue + seen.add(item) + + yield item + yield* walk(item, getList, seen) + } +}