From bef1f724f91555c372258923236e8053923f36b1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 4 Oct 2024 15:12:20 +0200 Subject: [PATCH 01/14] WIP --- integrations/upgrade/index.test.ts | 395 +++++++++++++++ .../migrate-at-layer-utilities.test.ts | 224 ++++++++- .../codemods/migrate-at-layer-utilities.ts | 33 +- .../src/codemods/migrate-missing-layers.ts | 3 +- .../@tailwindcss-upgrade/src/index.test.ts | 154 +++--- packages/@tailwindcss-upgrade/src/index.ts | 59 ++- packages/@tailwindcss-upgrade/src/migrate.ts | 475 +++++++++++++++++- 7 files changed, 1234 insertions(+), 109 deletions(-) diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 881f40010..5b29f30f7 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,397 @@ 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') + + await fs.expectFileToContain( + 'src/index.css', + css` + @import 'tailwindcss'; + @import './utilities.css' layer(utilities); + @import './utilities.twupgrade.css'; + `, + ) + + await fs.expectFileNotToContain( + 'src/utilities.css', + css` + @utility no-scrollbar { + &::-webkit-scrollbar { + display: none; + } + -ms-overflow-style: none; + scrollbar-width: none; + } + `, + ) + + await fs.expectFileNotToContain( + 'src/utilities.twupgrade.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';" + `) + expect(await fs.read('src/a.css')).toMatchInlineSnapshot(`"@import './utilities.css'"`) + expect(await fs.read('src/utilities.css')).toMatchInlineSnapshot(` + "#foo { + --keep: me; + }" + `) + expect(await fs.read('src/a.utilities.css')).toMatchInlineSnapshot(` + " + + @utility foo-from-import { + color: blue; + } + + @utility foo-from-a { + color: red; + }" + `) + expect(await fs.read('src/b.css')).toMatchInlineSnapshot(` + "@utility bar-from-import { + color: blue; + } + + @utility bar-from-b { + color: red; + }" + `) + expect(await fs.read('src/c.css')).toMatchInlineSnapshot() + expect(await fs.read('src/c-2.css')).toMatchInlineSnapshot() + expect(await fs.read('src/c.utilities.css')).toMatchInlineSnapshot() + + // await exec('npx @tailwindcss/cli -i src/index.css -o out.css') + + // expect(await fs.read('out.css')).toMatchInlineSnapshot() + }, +) + +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(` + " + + @utility from-a-3 { + color: blue; + } + @utility from-a-2 { + color: green; + } + @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.3.css')).toMatchInlineSnapshot(` + "#from-a-3 { + --keep: me; + }" + `) + }, +) + +test.debug( + '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);" + `) + expect(await fs.read('src/a.1.css')).toMatchInlineSnapshot(` + "@import './a.2.css' layer(utilities); + @import "./a.2.utilities.css"; + #from-a-1 { + --keep: me; + } + .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(` + "@utility from-a-4 { + color: blue; + } + @utility from-a-3 { + color: blue; + } + @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.4.css')).toMatchInlineSnapshot(` + "#from-a-4 { + --keep: me; + }" + `) + }, +) 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..02376ce71 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,24 @@ import dedent from 'dedent' import postcss from 'postcss' import { describe, expect, it } from 'vitest' +import type { Stylesheet } from '../migrate' import { formatNodes } from './format-nodes' import { migrateAtLayerUtilities } from './migrate-at-layer-utilities' const css = dedent -function migrate(input: string) { +function migrate(stylesheet: Stylesheet | string) { + if (typeof stylesheet === 'string') { + stylesheet = { + content: stylesheet, + root: postcss.parse(stylesheet), + } + } + 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 +828,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..b77da7c2c 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 type { Stylesheet } from '../migrate' 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?.includes('utilities') || stylesheet.layers?.includes('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..1ed070c17 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -1,11 +1,21 @@ #!/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, + prepare as prepareStylesheet, + split as splitStylesheets, + type MigrateOptions, + type Stylesheet, +} from './migrate' import { migrate as migrateTemplate } from './template/migrate' import { parseConfig } from './template/parseConfig' import { args, type Arg } from './utils/args' @@ -109,16 +119,45 @@ async function run() { // Ensure we are only dealing with CSS files files = files.filter((file) => file.endsWith('.css')) + // Analyze the stylesheets + let stylesheets: Stylesheet[] = files.map((file) => ({ file })) + + // Load and parse all stylesheets + await Promise.allSettled(stylesheets.map((sheet) => prepareStylesheet(sheet))) + + // Analyze the stylesheets + await analyzeStylesheets(stylesheets) + // 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, + } + await Promise.allSettled(stylesheets.map((sheet) => migrateStylesheet(sheet, options))) + + // Split up stylesheets (as needed) + await splitStylesheets(stylesheets) + + // Format nodes + for (let sheet of stylesheets) { + await postcss([formatNodes()]).process(sheet.root!, { from: sheet.file! }) + } + + // Write all files to disk + // 1. Unlink all files that are no longer needed go first + // 2. Write them all sequentially + for (let sheet of stylesheets.sort((a, z) => { + if (a.unlink && !z.unlink) return -1 + if (!a.unlink && z.unlink) return 1 + return 0 + })) { + if (sheet.unlink) { + await fs.unlink(sheet.file!) + } else { + 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..8d7c91fe9 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -1,13 +1,15 @@ import fs from 'node:fs/promises' import path from 'node:path' -import postcss from 'postcss' +import postcss, { AtRule } from 'postcss' +import postcssImport from 'postcss-import' import type { Config } from 'tailwindcss' import type { DesignSystem } from '../../tailwindcss/src/design-system' -import { formatNodes } from './codemods/format-nodes' +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 { walk, WalkAction } from './utils/walk' export interface MigrateOptions { newPrefix?: string @@ -15,20 +17,471 @@ export interface MigrateOptions { userConfig?: Config } -export async function migrateContents(contents: string, options: MigrateOptions, file?: string) { +export interface Stylesheet { + file?: string + unlink?: boolean + + rootFile?: string + rootImport?: postcss.AtRule + + content?: string | null + root?: postcss.Root | null + layers?: string[] + + parents?: Set + importRules?: Set +} + +export async function migrateContents( + stylesheet: Stylesheet | string, + options: MigrateOptions = {}, +) { + if (typeof stylesheet === 'string') { + stylesheet = { + content: stylesheet, + root: postcss.parse(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 }) } -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 mediaWrapper = `__wrapper__${Math.random().toString(16).slice(3, 8)}__` + + let stylesheetsByFile = new Map() + for (let stylesheet of stylesheets) { + if (!stylesheet.file) continue + stylesheetsByFile.set(stylesheet.file, stylesheet) + + stylesheet.layers ??= [] + stylesheet.importRules ??= new Set() + stylesheet.parents ??= new Set() + } + + // A list of all marker nodes used to annotate and analyze the AST + let importMarkers = new Set() + let fileMarkers = new Set() + + let processor = postcss([ + // Step 1: Add markers around the `@import` rules + // + // We need to mark the start and end of each `@import` rule so we can + // keep track of where they were in the original AST. We do this by cloning + // the node, renaming the original, and adding start/end markers around it. + { + postcssPlugin: 'import-thing', + Once(root) { + let imports = new Set() + + root.walkAtRules('import', (node) => { + imports.add(node) + }) + + for (let node of imports) { + // Duplicate the `@import` rule + // this will be the one that `postcss-import` processes + node.cloneAfter({ + params: `${node.params} ${mediaWrapper}`, + }) + + // Replace the original `@import` rule with a dummy comment + // it'll retain the original node in `raws` + let importMarker = postcss.comment({ + text: `__import_node__`, + raws: { original: node }, + }) + + importMarkers.add(importMarker) + node.replaceWith(importMarker) + } + }, + }, + + // Step 2: Expand `@import` rules and mark imported files + // + // Since valid imports are only at the top some files may not have any other + // nodes we can use to determine where in the AST a file was imported. To + // solve this, we'll add a marker at the top of each imported file which + // guarantees that we have a way to determine where the file was imported. + postcssImport({ + plugins: [ + { + postcssPlugin: 'import-marker', + Once(root) { + let marker = postcss.comment({ + text: `marker:imported-file`, + source: root.source, + }) + + fileMarkers.add(marker) + root.prepend(marker) + }, + }, + ], + }), + + // Step 3: Analyze the AST so each stylesheet can have each import node + // associated with it + { + postcssPlugin: 'import-thing2', + Once() { + // Associate import nodes with each stylesheet + for (let fileMarker of fileMarkers) { + let sourceFile = fileMarker.source?.input.file + if (!sourceFile) continue + + let stylesheet = stylesheetsByFile.get(sourceFile) + if (!stylesheet) continue + + // Find the closest import marker that precedes the file marker + let node = fileMarker + + while (node) { + if (importMarkers.has(node)) { + break + } + + let prev = node.prev() + + if (prev) { + // Walk backwards until we find a node that is a marker + node = prev + } else if (node.parent) { + // If there are no earlier siblings, go up a level and try again + node = node.parent + } else { + break + } + } + + // We were unable to find an import marker + // TODO: This should be an error + if (node === fileMarker) continue + + // TODO: This shouldn't be possible + if (!node.raws.original) continue + + stylesheet.importRules!.add(node.raws.original as AtRule) + } + + // Analyze import nodes to determine layers + for (let sheet of stylesheets) { + for (let node of sheet.importRules ?? []) { + let parts = segment(node.params, ' ') + for (let part of parts) { + if (!part.startsWith('layer(')) continue + if (!part.endsWith(')')) continue + + let layers = segment(part.slice(6, -1), ',').map((name) => name.trim()) + + sheet.layers!.push(...layers) + } + } + } + + // Connect all stylesheets together in a dependency graph + // The way this works is it uses the knowledge that we have a list of + // the `@import` nodes that cause a given stylesheet to be imported. + // That import has a `source` pointing to parent stylesheet's file path + // which can be used to look it up + for (let sheet of stylesheets) { + for (let node of sheet.importRules ?? []) { + if (!node.source?.input.file) continue + + let sourceFile = node.source.input.file + + for (let parent of stylesheets) { + if (parent.file !== sourceFile) continue + + // Track root import and `@import` node. This will allow us to + // hoist everything up to the root import when splitting the + // stylesheet. + sheet.rootFile = parent.rootFile ?? parent.file + sheet.rootImport = parent.rootImport ?? node + + sheet.parents!.add(parent) + } + } + } + }, + }, + + // Step 4: Restore the AST to its original state + { + postcssPlugin: 'import-thing2', + Once(root) { + // Replace the dummy comment nodes with the original `@import` nodes + for (let node of importMarkers) { + node.replaceWith(node.raws.original) + } + + // Remove all imported nodes + root.walkAtRules('media', (rule) => { + if (!rule.params.includes(mediaWrapper)) return + rule.remove() + }) + }, + }, + ]) + + for (let sheet of stylesheets) { + if (!sheet.file) continue + if (!sheet.root) continue + + await processor.process(sheet.root, { from: sheet.file }) + } +} + +export async function prepare(stylesheet: Stylesheet) { + if (stylesheet.file) { + stylesheet.file = path.resolve(process.cwd(), stylesheet.file) + stylesheet.content = await fs.readFile(stylesheet.file, 'utf-8') + } + + if (stylesheet.content) { + stylesheet.root = postcss.parse(stylesheet.content, { + from: stylesheet.file, + }) + } +} + +export async function split(stylesheets: Stylesheet[]) { + let utilitySheets = new Map() + let newRules: postcss.AtRule[] = [] + + for (let sheet of stylesheets.slice()) { + if (!sheet.root) continue + if (!sheet.file) continue + + // We only care about stylesheets that were imported into a layer e.g. `layer(utilities)` + let isLayered = sheet.layers?.includes('utilities') || sheet.layers?.includes('components') + if (!isLayered) continue + + // We only care about stylesheets that contain an `@utility` + let hasUtilities = false + + walk(sheet.root, (node) => { + if (node.type !== 'atrule') return + if (node.name !== 'utility') return + + hasUtilities = true + + return WalkAction.Stop + }) + + if (!hasUtilities) continue + + // Split the stylesheet into two parts: one with the utilities and one without + let utilities = postcss.root({ + raws: { + tailwind_pretty: true, + }, + }) + + walk(sheet.root, (node) => { + if (node.type !== 'atrule') return + if (node.name !== 'utility') return + + utilities.append(node) + + return WalkAction.Skip + }) + + // Add the import for the new utility file immediately following the old import + for (let node of sheet.importRules ?? []) { + // This node didn't have a `layer(…)` yet, but we added one during the + // migration. This means that we don't have to consider this node for the + // new import rule. + if (node.raws.tailwind_injected_layer) { + continue + } + + // Only interested in the main import rule with the layer + if (!node.params.includes('layer(utilities)') && !node.params.includes('layer(components)')) { + continue + } + + if (node !== sheet.rootImport) { + continue + } + + // We want to use the name of the main import, not the name of the + // transitive import. + // + // ```css + // /* index.css */ + // @import "./a.css" layer(utilities); + // + // /* a.css */ + // @import "./b.css"; + // + // /* b.css */ + // @layer utilities { + // .foo {} + // } + // ``` + // + // In this case we want `a.utilities.css` to be the name of the new file, + // not `b.utilities.css`. Every `@layer utilities` directive will be + // converted to `@utility` and will be hoisted to the `a.utilities.css` + // file. + + let relativePath = /['"](.*?)['"]/g.exec(node.params) + if (!relativePath) continue // This should never happen + if (!node.source?.input.file) continue // This should never happen + + let name = path.basename(relativePath[1]) + + let utilitySheet: Stylesheet = { + file: path.join(path.dirname(sheet.rootFile!), name.replace(/\.css$/, '.utilities.css')), + root: utilities, + } + + utilitySheets.set(sheet, utilitySheet) + + // Figure out the new import rule + let newParams = node.params.replace(/\.css(['"])/, '.utilities.css$1') + console.log(newParams) + + // Only add the new `@import` at-rule if it doesn't exist yet. + let existingNewImport = newRules.find((rule) => rule.params === newParams) + if (!existingNewImport) { + newRules.push( + node.cloneAfter({ + params: newParams, + raws: { + after: '\n\n', + tailwind_pretty: true, + }, + }), + ) + } + } + } + + // Merge utility sheets. + // It could be that the same type of file is created from two different + // locations. In this case, the final file will exist twice. + // E.g.: + // + // ```css + // /* index.css*/ + // @import './a.css' layer(utilities); + // + // /* a.css */ + // @import './b.css'; + // .foo {} /* <- generates a.utilities.css, key points to a.css */ + // + // /* b.css */ + // .bar {} /* <- generates a.utilities.css, key points to b.css */ + // ``` + let mergedUtilitySheets = new Map() + for (let utilitySheet of utilitySheets.values()) { + if (!utilitySheet.file) continue // Should never happen + + let existing = mergedUtilitySheets.get(utilitySheet.file) + if (!existing) { + mergedUtilitySheets.set(utilitySheet.file, utilitySheet) + } else { + // TODO: Not sure why a `prepend` is required instead of an `append`, but + // this results in the correct order. + existing.root?.prepend(utilitySheet.root?.nodes ?? []) + } + } + + // The new import rules should have just the filename import + // no layers, media queries, or anything else + for (let node of newRules) { + node.params = segment(node.params, ' ')[0] + } + + for (let [originalSheet, utilitySheet] of utilitySheets) { + utilitySheet.parents = new Set( + Array.from(originalSheet.parents ?? []).map((parent) => { + return utilitySheets.get(parent) ?? parent + }), + ) + } + + stylesheets.push(...mergedUtilitySheets.values()) + + // At this point, we probably created `{name}.utilities.css` files. If the + // original `{name}.css` is empty, then we can optimize the output a bit more + // by re-using the original file but just getting rid of the `layer + // (utilities)` marker. + // If removing files means that some `@import` at-rules are now unnecessary, we + // can also remove those. + { + // 1. Get rid of empty files (and their imports) + let repeat = true + while (repeat) { + repeat = false + for (let stylesheet of stylesheets) { + // Was already marked to be removed, skip + if (stylesheet.unlink) continue + + // Original content was not empty, but the new content is. Therefore we + // can mark the file for removal. + // TODO: Make sure that empty files are not even part of `stylesheets` + // in the first place. Then we can get rid of this check. + if (stylesheet.content?.trim() !== '' && stylesheet?.root?.toString().trim() === '') { + repeat = true + stylesheet.unlink = true + + // Cleanup imports that are now unnecessary + for (let parent of stylesheet.importRules ?? []) { + parent.remove() + } + } + } + } + + // 2. Use `{name}.css` instead of `{name}.utilities.css` if the `{name}.css` + // was marked for removal. + for (let [originalSheet, utilitySheet] of utilitySheets) { + // Original sheet was marked for removal, use the original file instead. + if (!originalSheet.unlink) continue + + // Fixup the import rule + for (let parent of originalSheet.importRules ?? []) { + parent.params = parent.params.replace(/\.utilities\.css(['"])/, '.css$1') + } + + // Fixup the file path + // utilitySheet.file = utilitySheet.file?.replace(/\.utilities\.css$/, '.css') + console.log('Cleanup', utilitySheet.file) + } + } +} + +// @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'; +// -> @import './c.css'; +// -> #main +// -> other stuff +// @import './a.utility.css'; +// -> @import './b.utility.css'; +// -> @import './c.utility.css'; +// -> @utility .utility-class From 87900fe5bc037c3a88e91f614602bda3e987d66a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 11:25:15 -0400 Subject: [PATCH 02/14] wip --- .../codemods/migrate-at-layer-utilities.ts | 2 +- packages/@tailwindcss-upgrade/src/index.ts | 21 +- packages/@tailwindcss-upgrade/src/migrate.ts | 271 +++++++----------- 3 files changed, 125 insertions(+), 169 deletions(-) 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 b77da7c2c..644cec719 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/migrate-at-layer-utilities.ts @@ -268,7 +268,7 @@ export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin { postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities', OnceExit: (root, { atRule }) => { let isUtilityStylesheet = - stylesheet.layers?.includes('utilities') || stylesheet.layers?.includes('components') + stylesheet.layers?.has('utilities') || stylesheet.layers?.has('components') if (isUtilityStylesheet) { let rule = atRule({ name: 'layer', params: 'utilities' }) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 1ed070c17..9b5f4e97d 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -123,7 +123,15 @@ async function run() { let stylesheets: Stylesheet[] = files.map((file) => ({ file })) // Load and parse all stylesheets - await Promise.allSettled(stylesheets.map((sheet) => prepareStylesheet(sheet))) + let prepareResults = await Promise.allSettled( + stylesheets.map((sheet) => prepareStylesheet(sheet)), + ) + + for (let result of prepareResults) { + if (result.status === 'rejected') { + error(`${result.reason}`) + } + } // Analyze the stylesheets await analyzeStylesheets(stylesheets) @@ -134,7 +142,16 @@ async function run() { designSystem: parsedConfig?.designSystem, userConfig: parsedConfig?.userConfig, } - await Promise.allSettled(stylesheets.map((sheet) => migrateStylesheet(sheet, options))) + + 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) await splitStylesheets(stylesheets) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 8d7c91fe9..5f672c44d 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -1,7 +1,6 @@ import fs from 'node:fs/promises' import path from 'node:path' import postcss, { AtRule } from 'postcss' -import postcssImport from 'postcss-import' import type { Config } from 'tailwindcss' import type { DesignSystem } from '../../tailwindcss/src/design-system' import { segment } from '../../tailwindcss/src/utils/segment' @@ -9,6 +8,7 @@ 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 { resolveCssId } from './utils/resolve' import { walk, WalkAction } from './utils/walk' export interface MigrateOptions { @@ -26,10 +26,12 @@ export interface Stylesheet { content?: string | null root?: postcss.Root | null - layers?: string[] + layers?: Set parents?: Set importRules?: Set + + readonly ancestors?: Set } export async function migrateContents( @@ -43,6 +45,9 @@ export async function migrateContents( } } + console.log(stylesheet.file) + console.log(stylesheet.root!.toString()) + return postcss() .use(migrateAtApply(options)) .use(migrateAtLayerUtilities(stylesheet)) @@ -67,173 +72,84 @@ export async function analyze(stylesheets: Stylesheet[]) { if (!stylesheet.file) continue stylesheetsByFile.set(stylesheet.file, stylesheet) - stylesheet.layers ??= [] + stylesheet.layers ??= new Set() stylesheet.importRules ??= new Set() stylesheet.parents ??= new Set() + + Object.defineProperty(stylesheet, 'ancestors', { + get: () => { + function* ancestors(sheet: Stylesheet): Iterable { + for (let parent of sheet.parents ?? []) { + yield parent + yield* ancestors(parent) + } + } + + return new Set(ancestors(stylesheet)) + }, + }) } // A list of all marker nodes used to annotate and analyze the AST let importMarkers = new Set() let fileMarkers = new Set() + // Step 1: Record which `@import` rules point to which stylesheets + // and which stylesheets are parents/children of each other let processor = postcss([ - // Step 1: Add markers around the `@import` rules - // - // We need to mark the start and end of each `@import` rule so we can - // keep track of where they were in the original AST. We do this by cloning - // the node, renaming the original, and adding start/end markers around it. { - postcssPlugin: 'import-thing', - Once(root) { - let imports = new Set() + postcssPlugin: 'mark-import-nodes', + AtRule: { + import(node) { + // Find what the import points to + let id = node.params.match(/['"](.*)['"]/)?.[1] + if (!id) return - root.walkAtRules('import', (node) => { - imports.add(node) - }) + let basePath = node.source?.input.file + ? path.dirname(node.source.input.file) + : process.cwd() - for (let node of imports) { - // Duplicate the `@import` rule - // this will be the one that `postcss-import` processes - node.cloneAfter({ - params: `${node.params} ${mediaWrapper}`, - }) + // 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 + } - // Replace the original `@import` rule with a dummy comment - // it'll retain the original node in `raws` - let importMarker = postcss.comment({ - text: `__import_node__`, - raws: { original: node }, - }) + if (!resolvedPath) return - importMarkers.add(importMarker) - node.replaceWith(importMarker) - } - }, - }, + // Find the stylesheet pointing to the resolved path + let stylesheet = stylesheetsByFile.get(resolvedPath) - // Step 2: Expand `@import` rules and mark imported files - // - // Since valid imports are only at the top some files may not have any other - // nodes we can use to determine where in the AST a file was imported. To - // solve this, we'll add a marker at the top of each imported file which - // guarantees that we have a way to determine where the file was imported. - postcssImport({ - plugins: [ - { - postcssPlugin: 'import-marker', - Once(root) { - let marker = postcss.comment({ - text: `marker:imported-file`, - source: root.source, - }) + // 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 - fileMarkers.add(marker) - root.prepend(marker) - }, + // If it does then this import node get added to that sylesheets `importRules` set + let parent = stylesheetsByFile.get(node.source?.input.file ?? '') + if (!parent) return + + // Record the import node for this sheet so it can be modified later + stylesheet.importRules!.add(node) + + // Connect all stylesheets together in a dependency graph + // The way this works is it uses the knowledge that we have a list of + // the `@import` nodes that cause a given stylesheet to be imported. + // That import has a `source` pointing to parent stylesheet's file path + // which can be used to look it up + stylesheet.parents!.add(parent) + + for (let part of segment(node.params, ' ')) { + if (!part.startsWith('layer(')) continue + if (!part.endsWith(')')) continue + + stylesheet.layers!.add(part.slice(6, -1).trim()) + } }, - ], - }), - - // Step 3: Analyze the AST so each stylesheet can have each import node - // associated with it - { - postcssPlugin: 'import-thing2', - Once() { - // Associate import nodes with each stylesheet - for (let fileMarker of fileMarkers) { - let sourceFile = fileMarker.source?.input.file - if (!sourceFile) continue - - let stylesheet = stylesheetsByFile.get(sourceFile) - if (!stylesheet) continue - - // Find the closest import marker that precedes the file marker - let node = fileMarker - - while (node) { - if (importMarkers.has(node)) { - break - } - - let prev = node.prev() - - if (prev) { - // Walk backwards until we find a node that is a marker - node = prev - } else if (node.parent) { - // If there are no earlier siblings, go up a level and try again - node = node.parent - } else { - break - } - } - - // We were unable to find an import marker - // TODO: This should be an error - if (node === fileMarker) continue - - // TODO: This shouldn't be possible - if (!node.raws.original) continue - - stylesheet.importRules!.add(node.raws.original as AtRule) - } - - // Analyze import nodes to determine layers - for (let sheet of stylesheets) { - for (let node of sheet.importRules ?? []) { - let parts = segment(node.params, ' ') - for (let part of parts) { - if (!part.startsWith('layer(')) continue - if (!part.endsWith(')')) continue - - let layers = segment(part.slice(6, -1), ',').map((name) => name.trim()) - - sheet.layers!.push(...layers) - } - } - } - - // Connect all stylesheets together in a dependency graph - // The way this works is it uses the knowledge that we have a list of - // the `@import` nodes that cause a given stylesheet to be imported. - // That import has a `source` pointing to parent stylesheet's file path - // which can be used to look it up - for (let sheet of stylesheets) { - for (let node of sheet.importRules ?? []) { - if (!node.source?.input.file) continue - - let sourceFile = node.source.input.file - - for (let parent of stylesheets) { - if (parent.file !== sourceFile) continue - - // Track root import and `@import` node. This will allow us to - // hoist everything up to the root import when splitting the - // stylesheet. - sheet.rootFile = parent.rootFile ?? parent.file - sheet.rootImport = parent.rootImport ?? node - - sheet.parents!.add(parent) - } - } - } - }, - }, - - // Step 4: Restore the AST to its original state - { - postcssPlugin: 'import-thing2', - Once(root) { - // Replace the dummy comment nodes with the original `@import` nodes - for (let node of importMarkers) { - node.replaceWith(node.raws.original) - } - - // Remove all imported nodes - root.walkAtRules('media', (rule) => { - if (!rule.params.includes(mediaWrapper)) return - rule.remove() - }) }, }, ]) @@ -244,6 +160,15 @@ export async function analyze(stylesheets: Stylesheet[]) { 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) + } + } + } } export async function prepare(stylesheet: Stylesheet) { @@ -268,7 +193,7 @@ export async function split(stylesheets: Stylesheet[]) { if (!sheet.file) continue // We only care about stylesheets that were imported into a layer e.g. `layer(utilities)` - let isLayered = sheet.layers?.includes('utilities') || sheet.layers?.includes('components') + let isLayered = sheet.layers?.has('utilities') || sheet.layers?.has('components') if (!isLayered) continue // We only care about stylesheets that contain an `@utility` @@ -283,6 +208,9 @@ export async function split(stylesheets: Stylesheet[]) { return WalkAction.Stop }) + console.log(sheet.file) + console.log(sheet.root.toString()) + if (!hasUtilities) continue // Split the stylesheet into two parts: one with the utilities and one without @@ -311,13 +239,13 @@ export async function split(stylesheets: Stylesheet[]) { } // Only interested in the main import rule with the layer - if (!node.params.includes('layer(utilities)') && !node.params.includes('layer(components)')) { - continue - } + // if (!node.params.includes('layer(utilities)') && !node.params.includes('layer(components)')) { + // continue + // } - if (node !== sheet.rootImport) { - continue - } + // if (node !== sheet.rootImport) { + // continue + // } // We want to use the name of the main import, not the name of the // transitive import. @@ -347,7 +275,7 @@ export async function split(stylesheets: Stylesheet[]) { let name = path.basename(relativePath[1]) let utilitySheet: Stylesheet = { - file: path.join(path.dirname(sheet.rootFile!), name.replace(/\.css$/, '.utilities.css')), + file: path.join(path.dirname(sheet.file!), name.replace(/\.css$/, '.utilities.css')), root: utilities, } @@ -373,6 +301,17 @@ export async function split(stylesheets: Stylesheet[]) { } } + console.dir( + [ + ...Array.from(stylesheets.slice(), (s) => [ + s.file, + Array.from(s.importRules ?? [], (r) => r.toString()), + ]), + Array.from(utilitySheets.values(), (s) => s.file), + ], + { depth: 1 }, + ) + // Merge utility sheets. // It could be that the same type of file is created from two different // locations. In this case, the final file will exist twice. @@ -468,6 +407,9 @@ export async function split(stylesheets: Stylesheet[]) { } } +// @import './a.css' layer(utilities) ; +// -> @utility { … } + // @import './a.css' layer(utilities); // -> @import './b.css'; // -> @import './c.css'; @@ -477,11 +419,8 @@ export async function split(stylesheets: Stylesheet[]) { // -> other stuff // @import './a.css' layer(utilities); -// -> @import './b.css'; +// -> @import './b.css'; (layers: utilities) // -> @import './c.css'; -// -> #main +// -> @import './d.css'; +// -> #main // -> other stuff -// @import './a.utility.css'; -// -> @import './b.utility.css'; -// -> @import './c.utility.css'; -// -> @utility .utility-class From 66a00ff84254d68f1a21ef7960500e77d5827165 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 11:25:50 -0400 Subject: [PATCH 03/14] wip --- packages/@tailwindcss-upgrade/src/migrate.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 5f672c44d..2ef210023 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -45,9 +45,6 @@ export async function migrateContents( } } - console.log(stylesheet.file) - console.log(stylesheet.root!.toString()) - return postcss() .use(migrateAtApply(options)) .use(migrateAtLayerUtilities(stylesheet)) @@ -65,8 +62,6 @@ export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { } export async function analyze(stylesheets: Stylesheet[]) { - let mediaWrapper = `__wrapper__${Math.random().toString(16).slice(3, 8)}__` - let stylesheetsByFile = new Map() for (let stylesheet of stylesheets) { if (!stylesheet.file) continue @@ -90,10 +85,6 @@ export async function analyze(stylesheets: Stylesheet[]) { }) } - // A list of all marker nodes used to annotate and analyze the AST - let importMarkers = new Set() - let fileMarkers = new Set() - // Step 1: Record which `@import` rules point to which stylesheets // and which stylesheets are parents/children of each other let processor = postcss([ @@ -208,9 +199,6 @@ export async function split(stylesheets: Stylesheet[]) { return WalkAction.Stop }) - console.log(sheet.file) - console.log(sheet.root.toString()) - if (!hasUtilities) continue // Split the stylesheet into two parts: one with the utilities and one without From ca55a77c20a861663798fd313ef1de04c49facd9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 12:16:43 -0400 Subject: [PATCH 04/14] wip --- packages/@tailwindcss-upgrade/src/migrate.ts | 233 +++++++++---------- 1 file changed, 105 insertions(+), 128 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 2ef210023..d2810e9ea 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -29,9 +29,12 @@ export interface Stylesheet { layers?: Set parents?: Set + children?: Set importRules?: Set + hasUtilities?: boolean readonly ancestors?: Set + readonly descendants?: Set } export async function migrateContents( @@ -70,18 +73,24 @@ export async function analyze(stylesheets: Stylesheet[]) { stylesheet.layers ??= new Set() stylesheet.importRules ??= new Set() stylesheet.parents ??= new Set() + stylesheet.children ??= new Set() + + function* traverse( + sheet: Stylesheet, + list: (sheet: Stylesheet) => Iterable, + ): Iterable { + for (let child of list(sheet)) { + yield child + yield* traverse(child, list) + } + } Object.defineProperty(stylesheet, 'ancestors', { - get: () => { - function* ancestors(sheet: Stylesheet): Iterable { - for (let parent of sheet.parents ?? []) { - yield parent - yield* ancestors(parent) - } - } + get: () => new Set(traverse(stylesheet, (sheet) => sheet.parents ?? [])), + }) - return new Set(ancestors(stylesheet)) - }, + Object.defineProperty(stylesheet, 'descendants', { + get: () => new Set(traverse(stylesheet, (sheet) => sheet.children ?? [])), }) } @@ -127,12 +136,17 @@ export async function analyze(stylesheets: Stylesheet[]) { // Record the import node for this sheet so it can be modified later stylesheet.importRules!.add(node) + node.raws.sheets ??= new Set() + // @ts-ignore + node.raws.sheets!.add(stylesheet) + // Connect all stylesheets together in a dependency graph // The way this works is it uses the knowledge that we have a list of // the `@import` nodes that cause a given stylesheet to be imported. // That import has a `source` pointing to parent stylesheet's file path // which can be used to look it up stylesheet.parents!.add(parent) + parent.children!.add(stylesheet) for (let part of segment(node.params, ' ')) { if (!part.startsWith('layer(')) continue @@ -179,7 +193,7 @@ export async function split(stylesheets: Stylesheet[]) { let utilitySheets = new Map() let newRules: postcss.AtRule[] = [] - for (let sheet of stylesheets.slice()) { + for (let sheet of stylesheets) { if (!sheet.root) continue if (!sheet.file) continue @@ -199,7 +213,20 @@ export async function split(stylesheets: Stylesheet[]) { return WalkAction.Stop }) - if (!hasUtilities) continue + sheet.hasUtilities = hasUtilities + } + + for (let sheet of stylesheets) { + if (!sheet.root) continue + if (!sheet.importRules?.size) continue + + // Skip stylesheets that don't have utilities + // and don't have any children that have utilities + if (!sheet.hasUtilities) { + if (!Array.from(sheet.descendants ?? []).some((child) => child.hasUtilities)) { + continue + } + } // Split the stylesheet into two parts: one with the utilities and one without let utilities = postcss.root({ @@ -217,134 +244,84 @@ export async function split(stylesheets: Stylesheet[]) { return WalkAction.Skip }) - // Add the import for the new utility file immediately following the old import - for (let node of sheet.importRules ?? []) { - // This node didn't have a `layer(…)` yet, but we added one during the - // migration. This means that we don't have to consider this node for the - // new import rule. - if (node.raws.tailwind_injected_layer) { - continue + let utilitySheet: Stylesheet = { + file: sheet.file!.replace(/\.css$/, '.utilities.css'), + root: utilities, + importRules: new Set(), + parents: new Set(), + children: new Set(), + layers: new Set(), + hasUtilities: true, + } + + utilitySheets.set(sheet, utilitySheet) + } + + for (let sheet of stylesheets) { + if (!sheet.root) continue + + let utilitySheet = utilitySheets.get(sheet) + + let importNodes = new Set() + sheet.root.walkAtRules('import', (node) => { + importNodes.add(node) + }) + + let utilityImports: Set = new Set() + + for (let node of importNodes) { + if (!node.raws.sheets) continue + + let id = node.params.match(/['"](.*)['"]/)?.[1] + if (!id) return + + let normalSheetForImport = Array.from(sheet.children ?? [])?.find((child) => { + return child.importRules?.has(node) + }) + let utilitySheetForImport = utilitySheets.get(normalSheetForImport!) + + let newFile = id.replace(/\.css$/, '.utilities.css') + let newImport = node.clone({ + params: `"${newFile}"`, + raws: { + after: '\n\n', + }, + }) + + if (utilitySheet) { + utilityImports.add(newImport) + utilitySheetForImport?.importRules?.add(newImport) + } else { + node.after(newImport) } + } - // Only interested in the main import rule with the layer - // if (!node.params.includes('layer(utilities)') && !node.params.includes('layer(components)')) { - // continue - // } - - // if (node !== sheet.rootImport) { - // continue - // } - - // We want to use the name of the main import, not the name of the - // transitive import. - // - // ```css - // /* index.css */ - // @import "./a.css" layer(utilities); - // - // /* a.css */ - // @import "./b.css"; - // - // /* b.css */ - // @layer utilities { - // .foo {} - // } - // ``` - // - // In this case we want `a.utilities.css` to be the name of the new file, - // not `b.utilities.css`. Every `@layer utilities` directive will be - // converted to `@utility` and will be hoisted to the `a.utilities.css` - // file. - - let relativePath = /['"](.*?)['"]/g.exec(node.params) - if (!relativePath) continue // This should never happen - if (!node.source?.input.file) continue // This should never happen - - let name = path.basename(relativePath[1]) - - let utilitySheet: Stylesheet = { - file: path.join(path.dirname(sheet.file!), name.replace(/\.css$/, '.utilities.css')), - root: utilities, - } - - utilitySheets.set(sheet, utilitySheet) - - // Figure out the new import rule - let newParams = node.params.replace(/\.css(['"])/, '.utilities.css$1') - console.log(newParams) - - // Only add the new `@import` at-rule if it doesn't exist yet. - let existingNewImport = newRules.find((rule) => rule.params === newParams) - if (!existingNewImport) { - newRules.push( - node.cloneAfter({ - params: newParams, - raws: { - after: '\n\n', - tailwind_pretty: true, - }, - }), - ) - } + if (utilitySheet && utilityImports.size > 0) { + utilitySheet.root!.prepend(Array.from(utilityImports)) } } - console.dir( - [ - ...Array.from(stylesheets.slice(), (s) => [ - s.file, - Array.from(s.importRules ?? [], (r) => r.toString()), - ]), - Array.from(utilitySheets.values(), (s) => s.file), - ], - { depth: 1 }, - ) + // Make sure the utility sheets track parents and import nodes and what not + for (let [normalSheet, utilitySheet] of utilitySheets) { + if (!utilitySheet.parents) continue + if (!utilitySheet.children) continue - // Merge utility sheets. - // It could be that the same type of file is created from two different - // locations. In this case, the final file will exist twice. - // E.g.: - // - // ```css - // /* index.css*/ - // @import './a.css' layer(utilities); - // - // /* a.css */ - // @import './b.css'; - // .foo {} /* <- generates a.utilities.css, key points to a.css */ - // - // /* b.css */ - // .bar {} /* <- generates a.utilities.css, key points to b.css */ - // ``` - let mergedUtilitySheets = new Map() - for (let utilitySheet of utilitySheets.values()) { - if (!utilitySheet.file) continue // Should never happen + for (let parent of normalSheet.parents ?? []) { + let utilityParent = utilitySheets.get(parent) + if (!utilityParent) continue + utilitySheet.parents.add(utilityParent) + } - let existing = mergedUtilitySheets.get(utilitySheet.file) - if (!existing) { - mergedUtilitySheets.set(utilitySheet.file, utilitySheet) - } else { - // TODO: Not sure why a `prepend` is required instead of an `append`, but - // this results in the correct order. - existing.root?.prepend(utilitySheet.root?.nodes ?? []) + for (let child of normalSheet.children ?? []) { + let utilityChild = utilitySheets.get(child) + if (!utilityChild) continue + utilitySheet.children.add(utilityChild) } } - // The new import rules should have just the filename import - // no layers, media queries, or anything else - for (let node of newRules) { - node.params = segment(node.params, ' ')[0] - } + stylesheets.push(...utilitySheets.values()) - for (let [originalSheet, utilitySheet] of utilitySheets) { - utilitySheet.parents = new Set( - Array.from(originalSheet.parents ?? []).map((parent) => { - return utilitySheets.get(parent) ?? parent - }), - ) - } - - stylesheets.push(...mergedUtilitySheets.values()) + return // At this point, we probably created `{name}.utilities.css` files. If the // original `{name}.css` is empty, then we can optimize the output a bit more From 6a0da6fa56b990dc84c8050da1d5480f7b6c87bf Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 13:10:41 -0400 Subject: [PATCH 05/14] wip --- packages/@tailwindcss-upgrade/src/index.ts | 12 ++- packages/@tailwindcss-upgrade/src/migrate.ts | 107 ++++++++----------- 2 files changed, 53 insertions(+), 66 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 9b5f4e97d..528e7cc2b 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -134,7 +134,11 @@ async function run() { } // Analyze the stylesheets - await analyzeStylesheets(stylesheets) + try { + await analyzeStylesheets(stylesheets) + } catch (e: unknown) { + error(`${e}`) + } // Migrate each file let options: MigrateOptions = { @@ -154,7 +158,11 @@ async function run() { } // Split up stylesheets (as needed) - await splitStylesheets(stylesheets) + try { + await splitStylesheets(stylesheets) + } catch (e: unknown) { + error(`${e}`) + } // Format nodes for (let sheet of stylesheets) { diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index d2810e9ea..f44698f6d 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -30,7 +30,8 @@ export interface Stylesheet { parents?: Set children?: Set - importRules?: Set + importsFromParents?: Set + importsInSelf?: Set hasUtilities?: boolean readonly ancestors?: Set @@ -71,7 +72,8 @@ export async function analyze(stylesheets: Stylesheet[]) { stylesheetsByFile.set(stylesheet.file, stylesheet) stylesheet.layers ??= new Set() - stylesheet.importRules ??= new Set() + stylesheet.importsFromParents ??= new Set() + stylesheet.importsInSelf ??= new Set() stylesheet.parents ??= new Set() stylesheet.children ??= new Set() @@ -129,16 +131,13 @@ export async function analyze(stylesheets: Stylesheet[]) { // that we don't want to modify if (!stylesheet) return - // If it does then this import node get added to that sylesheets `importRules` set + // If it does then this import node get added to that sylesheets `importsFromParents` set let parent = stylesheetsByFile.get(node.source?.input.file ?? '') if (!parent) return // Record the import node for this sheet so it can be modified later - stylesheet.importRules!.add(node) - - node.raws.sheets ??= new Set() - // @ts-ignore - node.raws.sheets!.add(stylesheet) + stylesheet.importsFromParents!.add(node) + parent.importsInSelf!.add(node) // Connect all stylesheets together in a dependency graph // The way this works is it uses the knowledge that we have a list of @@ -191,7 +190,6 @@ export async function prepare(stylesheet: Stylesheet) { export async function split(stylesheets: Stylesheet[]) { let utilitySheets = new Map() - let newRules: postcss.AtRule[] = [] for (let sheet of stylesheets) { if (!sheet.root) continue @@ -218,7 +216,7 @@ export async function split(stylesheets: Stylesheet[]) { for (let sheet of stylesheets) { if (!sheet.root) continue - if (!sheet.importRules?.size) continue + if (!sheet.importsFromParents?.size) continue // Skip stylesheets that don't have utilities // and don't have any children that have utilities @@ -247,7 +245,8 @@ export async function split(stylesheets: Stylesheet[]) { let utilitySheet: Stylesheet = { file: sheet.file!.replace(/\.css$/, '.utilities.css'), root: utilities, - importRules: new Set(), + importsFromParents: new Set(), + importsInSelf: new Set(), parents: new Set(), children: new Set(), layers: new Set(), @@ -261,25 +260,15 @@ export async function split(stylesheets: Stylesheet[]) { if (!sheet.root) continue let utilitySheet = utilitySheets.get(sheet) - - let importNodes = new Set() - sheet.root.walkAtRules('import', (node) => { - importNodes.add(node) - }) - let utilityImports: Set = new Set() - for (let node of importNodes) { - if (!node.raws.sheets) continue + console.log(`---- ${sheet.file} ----`) + console.log(Array.from(sheet.importsInSelf ?? []).map((node) => node.toString())) + for (let node of sheet.importsInSelf ?? []) { let id = node.params.match(/['"](.*)['"]/)?.[1] if (!id) return - let normalSheetForImport = Array.from(sheet.children ?? [])?.find((child) => { - return child.importRules?.has(node) - }) - let utilitySheetForImport = utilitySheets.get(normalSheetForImport!) - let newFile = id.replace(/\.css$/, '.utilities.css') let newImport = node.clone({ params: `"${newFile}"`, @@ -290,7 +279,13 @@ export async function split(stylesheets: Stylesheet[]) { if (utilitySheet) { utilityImports.add(newImport) - utilitySheetForImport?.importRules?.add(newImport) + utilitySheet.importsInSelf!.add(newImport) + + for (let child of sheet.children ?? []) { + if (child.importsFromParents?.has(node)) { + utilitySheets.get(child)!.importsFromParents!.add(newImport) + } + } } else { node.after(newImport) } @@ -319,57 +314,41 @@ export async function split(stylesheets: Stylesheet[]) { } } - stylesheets.push(...utilitySheets.values()) - - return - // At this point, we probably created `{name}.utilities.css` files. If the // original `{name}.css` is empty, then we can optimize the output a bit more // by re-using the original file but just getting rid of the `layer // (utilities)` marker. // If removing files means that some `@import` at-rules are now unnecessary, we // can also remove those. - { - // 1. Get rid of empty files (and their imports) - let repeat = true - while (repeat) { - repeat = false - for (let stylesheet of stylesheets) { - // Was already marked to be removed, skip - if (stylesheet.unlink) continue + for (let sheet of stylesheets) { + if (!sheet.root) continue - // Original content was not empty, but the new content is. Therefore we - // can mark the file for removal. - // TODO: Make sure that empty files are not even part of `stylesheets` - // in the first place. Then we can get rid of this check. - if (stylesheet.content?.trim() !== '' && stylesheet?.root?.toString().trim() === '') { - repeat = true - stylesheet.unlink = true + let utilitySheet = utilitySheets.get(sheet) + if (!utilitySheet) continue - // Cleanup imports that are now unnecessary - for (let parent of stylesheet.importRules ?? []) { - parent.remove() - } - } - } + if (sheet.root.toString().trim() !== '') 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. Point the imports back to the original file since we don't need the utility file anymore + for (let node of utilitySheet.importsFromParents ?? []) { + node.params = node.params.replace(/\.utilities\.css['"]/, '.css') } - // 2. Use `{name}.css` instead of `{name}.utilities.css` if the `{name}.css` - // was marked for removal. - for (let [originalSheet, utilitySheet] of utilitySheets) { - // Original sheet was marked for removal, use the original file instead. - if (!originalSheet.unlink) continue - - // Fixup the import rule - for (let parent of originalSheet.importRules ?? []) { - parent.params = parent.params.replace(/\.utilities\.css(['"])/, '.css$1') - } - - // Fixup the file path - // utilitySheet.file = utilitySheet.file?.replace(/\.utilities\.css$/, '.css') - console.log('Cleanup', utilitySheet.file) + // 3. Remove the original import from the non-utility sheet + // TODO: This does not work because we're cloning trees during the migration + // we *cannot* rely on reference semantics at all for any postcss nodes + for (let node of sheet.importsFromParents ?? []) { + node.remove() } + + // 3. Mark the utility sheet for removal + utilitySheets.delete(sheet) } + + stylesheets.push(...utilitySheets.values()) } // @import './a.css' layer(utilities) ; From ba93e02a4e42682f3e5ac2fa3d943b2882840f3b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 13:17:14 -0400 Subject: [PATCH 06/14] wip --- packages/@tailwindcss-upgrade/src/index.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 528e7cc2b..c1977391b 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -172,16 +172,8 @@ async function run() { // Write all files to disk // 1. Unlink all files that are no longer needed go first // 2. Write them all sequentially - for (let sheet of stylesheets.sort((a, z) => { - if (a.unlink && !z.unlink) return -1 - if (!a.unlink && z.unlink) return 1 - return 0 - })) { - if (sheet.unlink) { - await fs.unlink(sheet.file!) - } else { - await fs.writeFile(sheet.file!, sheet.root!.toString()) - } + for (let sheet of stylesheets) { + await fs.writeFile(sheet.file!, sheet.root!.toString()) } success('Stylesheet migration complete.') From cae3cc7ba27f7e3aefbeda0a19e5c895eaacf9b1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 13:17:26 -0400 Subject: [PATCH 07/14] wip --- packages/@tailwindcss-upgrade/src/migrate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index f44698f6d..9891077a6 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -321,6 +321,7 @@ export async function split(stylesheets: Stylesheet[]) { // If removing files means that some `@import` at-rules are now unnecessary, we // can also remove those. for (let sheet of stylesheets) { + continue if (!sheet.root) continue let utilitySheet = utilitySheets.get(sheet) From 0bb3e0863df1478704885cdb1184fe27415b85ad Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 14:33:56 -0400 Subject: [PATCH 08/14] Refactor --- packages/@tailwindcss-upgrade/src/index.ts | 15 +- packages/@tailwindcss-upgrade/src/migrate.ts | 141 ++++-------------- .../@tailwindcss-upgrade/src/stylesheet.ts | 86 +++++++++++ 3 files changed, 126 insertions(+), 116 deletions(-) create mode 100644 packages/@tailwindcss-upgrade/src/stylesheet.ts diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index c1977391b..99d6e133a 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -11,11 +11,10 @@ import { help } from './commands/help' import { analyze as analyzeStylesheets, migrate as migrateStylesheet, - prepare as prepareStylesheet, split as splitStylesheets, type MigrateOptions, - type Stylesheet, } 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' @@ -120,19 +119,19 @@ async function run() { files = files.filter((file) => file.endsWith('.css')) // Analyze the stylesheets - let stylesheets: Stylesheet[] = files.map((file) => ({ file })) + let loadResults = await Promise.allSettled(files.map((filepath) => Stylesheet.load(filepath))) // Load and parse all stylesheets - let prepareResults = await Promise.allSettled( - stylesheets.map((sheet) => prepareStylesheet(sheet)), - ) - - for (let result of prepareResults) { + 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) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 9891077a6..874c9aa2c 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -1,6 +1,5 @@ -import fs from 'node:fs/promises' import path from 'node:path' -import postcss, { AtRule } from 'postcss' +import postcss from 'postcss' import type { Config } from 'tailwindcss' import type { DesignSystem } from '../../tailwindcss/src/design-system' import { segment } from '../../tailwindcss/src/utils/segment' @@ -8,6 +7,7 @@ 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 } from './stylesheet' import { resolveCssId } from './utils/resolve' import { walk, WalkAction } from './utils/walk' @@ -17,36 +17,12 @@ export interface MigrateOptions { userConfig?: Config } -export interface Stylesheet { - file?: string - unlink?: boolean - - rootFile?: string - rootImport?: postcss.AtRule - - content?: string | null - root?: postcss.Root | null - layers?: Set - - parents?: Set - children?: Set - importsFromParents?: Set - importsInSelf?: Set - hasUtilities?: boolean - - readonly ancestors?: Set - readonly descendants?: Set -} - export async function migrateContents( stylesheet: Stylesheet | string, options: MigrateOptions = {}, ) { if (typeof stylesheet === 'string') { - stylesheet = { - content: stylesheet, - root: postcss.parse(stylesheet), - } + stylesheet = await Stylesheet.fromString(stylesheet) } return postcss() @@ -54,7 +30,7 @@ export async function migrateContents( .use(migrateAtLayerUtilities(stylesheet)) .use(migrateMissingLayers()) .use(migrateTailwindDirectives(options)) - .process(stylesheet.root!, { from: stylesheet.file }) + .process(stylesheet.root, { from: stylesheet.file ?? undefined }) } export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { @@ -67,33 +43,11 @@ export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) { export async function analyze(stylesheets: Stylesheet[]) { let stylesheetsByFile = new Map() - for (let stylesheet of stylesheets) { - if (!stylesheet.file) continue - stylesheetsByFile.set(stylesheet.file, stylesheet) - stylesheet.layers ??= new Set() - stylesheet.importsFromParents ??= new Set() - stylesheet.importsInSelf ??= new Set() - stylesheet.parents ??= new Set() - stylesheet.children ??= new Set() - - function* traverse( - sheet: Stylesheet, - list: (sheet: Stylesheet) => Iterable, - ): Iterable { - for (let child of list(sheet)) { - yield child - yield* traverse(child, list) - } + for (let sheet of stylesheets) { + if (sheet.file) { + stylesheetsByFile.set(sheet.file, sheet) } - - Object.defineProperty(stylesheet, 'ancestors', { - get: () => new Set(traverse(stylesheet, (sheet) => sheet.parents ?? [])), - }) - - Object.defineProperty(stylesheet, 'descendants', { - get: () => new Set(traverse(stylesheet, (sheet) => sheet.children ?? [])), - }) } // Step 1: Record which `@import` rules point to which stylesheets @@ -136,22 +90,22 @@ export async function analyze(stylesheets: Stylesheet[]) { if (!parent) return // Record the import node for this sheet so it can be modified later - stylesheet.importsFromParents!.add(node) - parent.importsInSelf!.add(node) + stylesheet.importsFromParents.add(node) + parent.importsInSelf.add(node) // Connect all stylesheets together in a dependency graph // The way this works is it uses the knowledge that we have a list of // the `@import` nodes that cause a given stylesheet to be imported. // That import has a `source` pointing to parent stylesheet's file path // which can be used to look it up - stylesheet.parents!.add(parent) - parent.children!.add(stylesheet) + 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()) + stylesheet.layers.add(part.slice(6, -1).trim()) } }, }, @@ -160,43 +114,28 @@ export async function analyze(stylesheets: Stylesheet[]) { for (let sheet of stylesheets) { if (!sheet.file) continue - if (!sheet.root) 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) + for (let ancestor of sheet.ancestors) { + for (let layer of ancestor.layers) { + sheet.layers.add(layer) } } } } -export async function prepare(stylesheet: Stylesheet) { - if (stylesheet.file) { - stylesheet.file = path.resolve(process.cwd(), stylesheet.file) - stylesheet.content = await fs.readFile(stylesheet.file, 'utf-8') - } - - if (stylesheet.content) { - stylesheet.root = postcss.parse(stylesheet.content, { - from: stylesheet.file, - }) - } -} - export async function split(stylesheets: Stylesheet[]) { let utilitySheets = new Map() for (let sheet of stylesheets) { - if (!sheet.root) continue if (!sheet.file) continue // We only care about stylesheets that were imported into a layer e.g. `layer(utilities)` - let isLayered = sheet.layers?.has('utilities') || sheet.layers?.has('components') + let isLayered = sheet.layers.has('utilities') || sheet.layers.has('components') if (!isLayered) continue // We only care about stylesheets that contain an `@utility` @@ -215,13 +154,12 @@ export async function split(stylesheets: Stylesheet[]) { } for (let sheet of stylesheets) { - if (!sheet.root) continue - if (!sheet.importsFromParents?.size) continue + if (!sheet.importsFromParents.size) continue // Skip stylesheets that don't have utilities // and don't have any children that have utilities if (!sheet.hasUtilities) { - if (!Array.from(sheet.descendants ?? []).some((child) => child.hasUtilities)) { + if (!Array.from(sheet.descendants).some((child) => child.hasUtilities)) { continue } } @@ -242,30 +180,22 @@ export async function split(stylesheets: Stylesheet[]) { return WalkAction.Skip }) - let utilitySheet: Stylesheet = { - file: sheet.file!.replace(/\.css$/, '.utilities.css'), - root: utilities, - importsFromParents: new Set(), - importsInSelf: new Set(), - parents: new Set(), - children: new Set(), - layers: new Set(), - hasUtilities: true, - } + let utilitySheet = await Stylesheet.fromRoot( + utilities, + sheet.file!.replace(/\.css$/, '.utilities.css'), + ) utilitySheets.set(sheet, utilitySheet) } for (let sheet of stylesheets) { - if (!sheet.root) continue - let utilitySheet = utilitySheets.get(sheet) let utilityImports: Set = new Set() console.log(`---- ${sheet.file} ----`) - console.log(Array.from(sheet.importsInSelf ?? []).map((node) => node.toString())) + console.log(Array.from(sheet.importsInSelf).map((node) => node.toString())) - for (let node of sheet.importsInSelf ?? []) { + for (let node of sheet.importsInSelf) { let id = node.params.match(/['"](.*)['"]/)?.[1] if (!id) return @@ -279,11 +209,11 @@ export async function split(stylesheets: Stylesheet[]) { if (utilitySheet) { utilityImports.add(newImport) - utilitySheet.importsInSelf!.add(newImport) + utilitySheet.importsInSelf.add(newImport) - for (let child of sheet.children ?? []) { - if (child.importsFromParents?.has(node)) { - utilitySheets.get(child)!.importsFromParents!.add(newImport) + for (let child of sheet.children) { + if (child.importsFromParents.has(node)) { + utilitySheets.get(child)!.importsFromParents.add(newImport) } } } else { @@ -292,22 +222,19 @@ export async function split(stylesheets: Stylesheet[]) { } if (utilitySheet && utilityImports.size > 0) { - utilitySheet.root!.prepend(Array.from(utilityImports)) + utilitySheet.root.prepend(Array.from(utilityImports)) } } // Make sure the utility sheets track parents and import nodes and what not for (let [normalSheet, utilitySheet] of utilitySheets) { - if (!utilitySheet.parents) continue - if (!utilitySheet.children) continue - - for (let parent of normalSheet.parents ?? []) { + for (let parent of normalSheet.parents) { let utilityParent = utilitySheets.get(parent) if (!utilityParent) continue utilitySheet.parents.add(utilityParent) } - for (let child of normalSheet.children ?? []) { + for (let child of normalSheet.children) { let utilityChild = utilitySheets.get(child) if (!utilityChild) continue utilitySheet.children.add(utilityChild) @@ -322,8 +249,6 @@ export async function split(stylesheets: Stylesheet[]) { // can also remove those. for (let sheet of stylesheets) { continue - if (!sheet.root) continue - let utilitySheet = utilitySheets.get(sheet) if (!utilitySheet) continue @@ -334,14 +259,14 @@ export async function split(stylesheets: Stylesheet[]) { sheet.root = utilitySheet.root // 2. Point the imports back to the original file since we don't need the utility file anymore - for (let node of utilitySheet.importsFromParents ?? []) { + for (let node of utilitySheet.importsFromParents) { node.params = node.params.replace(/\.utilities\.css['"]/, '.css') } // 3. Remove the original import from the non-utility sheet // TODO: This does not work because we're cloning trees during the migration // we *cannot* rely on reference semantics at all for any postcss nodes - for (let node of sheet.importsFromParents ?? []) { + for (let node of sheet.importsFromParents) { node.remove() } diff --git a/packages/@tailwindcss-upgrade/src/stylesheet.ts b/packages/@tailwindcss-upgrade/src/stylesheet.ts new file mode 100644 index 000000000..1030229a1 --- /dev/null +++ b/packages/@tailwindcss-upgrade/src/stylesheet.ts @@ -0,0 +1,86 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as postcss from 'postcss' + +export class Stylesheet { + /** + * A unique identifier for this stylesheet + * + * Used to track the stylesheet in PostCSS nodes. + */ + id: string + + /** + * 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() + + importsFromParents = new Set() + importsInSelf = new Set() + hasUtilities = false + + constructor(root: postcss.Root, file?: string) { + this.id = crypto.randomUUID() + this.root = root + this.file = file ?? null + } + + get ancestors() { + return walk(this, (sheet) => sheet.parents ?? []) + } + + get descendants() { + return walk(this, (sheet) => sheet.children ?? []) + } + + 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) + } +} + +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) + } +} From f387d88e46f16f84cf87dba15466a30387d03477 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 16:08:58 -0400 Subject: [PATCH 09/14] wip --- packages/@tailwindcss-upgrade/src/migrate.ts | 148 ++++++++++++------ .../@tailwindcss-upgrade/src/stylesheet.ts | 61 +++++--- 2 files changed, 138 insertions(+), 71 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index 874c9aa2c..e5e65100d 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -2,12 +2,13 @@ import path from 'node:path' import postcss from 'postcss' import type { Config } from 'tailwindcss' import type { DesignSystem } from '../../tailwindcss/src/design-system' +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 } from './stylesheet' +import { Stylesheet, type StylesheetId } from './stylesheet' import { resolveCssId } from './utils/resolve' import { walk, WalkAction } from './utils/walk' @@ -85,21 +86,19 @@ export async function analyze(stylesheets: Stylesheet[]) { // that we don't want to modify if (!stylesheet) return - // If it does then this import node get added to that sylesheets `importsFromParents` set - let parent = stylesheetsByFile.get(node.source?.input.file ?? '') - if (!parent) 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 - // Record the import node for this sheet so it can be modified later - stylesheet.importsFromParents.add(node) - parent.importsInSelf.add(node) + let parent = node.source?.input.file + ? stylesheetsByFile.get(node.source.input.file) + : undefined - // Connect all stylesheets together in a dependency graph - // The way this works is it uses the knowledge that we have a list of - // the `@import` nodes that cause a given stylesheet to be imported. - // That import has a `source` pointing to parent stylesheet's file path - // which can be used to look it up - stylesheet.parents.add(parent) - parent.children.add(stylesheet) + // 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 @@ -129,42 +128,46 @@ export async function analyze(stylesheets: Stylesheet[]) { } export async function split(stylesheets: Stylesheet[]) { - let utilitySheets = new Map() - + let stylesheetsById = new Map() for (let sheet of stylesheets) { - if (!sheet.file) continue + stylesheetsById.set(sheet.id, sheet) + } - // We only care about stylesheets that were imported into a layer e.g. `layer(utilities)` + // 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 - // We only care about stylesheets that contain an `@utility` - let hasUtilities = false - walk(sheet.root, (node) => { if (node.type !== 'atrule') return if (node.name !== 'utility') return - hasUtilities = true + containsUtilities.add(sheet) return WalkAction.Stop }) - - sheet.hasUtilities = hasUtilities } + // Split every imported stylesheet into two parts + // - one with the utilities and one without + let utilitySheets = new Map() + for (let sheet of stylesheets) { - if (!sheet.importsFromParents.size) continue + // 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 (!sheet.hasUtilities) { - if (!Array.from(sheet.descendants).some((child) => child.hasUtilities)) { + if (!containsUtilities.has(sheet)) { + if (!Array.from(sheet.descendants).some((child) => containsUtilities.has(child))) { continue } } - // Split the stylesheet into two parts: one with the utilities and one without let utilities = postcss.root({ raws: { tailwind_pretty: true, @@ -175,6 +178,8 @@ export async function split(stylesheets: Stylesheet[]) { 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 @@ -182,24 +187,37 @@ export async function split(stylesheets: Stylesheet[]) { let utilitySheet = await Stylesheet.fromRoot( utilities, - sheet.file!.replace(/\.css$/, '.utilities.css'), + 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() - console.log(`---- ${sheet.file} ----`) - console.log(Array.from(sheet.importsInSelf).map((node) => node.toString())) + 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 + + // 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 + let originalDestination = stylesheetsById.get(sheetId) + if (!originalDestination) continue - for (let node of sheet.importsInSelf) { 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: { @@ -208,25 +226,29 @@ export async function split(stylesheets: Stylesheet[]) { }) 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) - utilitySheet.importsInSelf.add(newImport) - for (let child of sheet.children) { - if (child.importsFromParents.has(node)) { - utilitySheets.get(child)!.importsFromParents.add(newImport) - } - } + let utilityDestination = utilitySheets.get(originalDestination) + if (!utilityDestination) continue + + newImport.raws.tailwind_destination_sheet_id = utilityDestination.id } else { + // This import will go immediately after the original import node.after(newImport) + newImport.raws.tailwind_destination_sheet_id = node.raws.tailwind_destination_sheet_id } } + // 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 track parents and import nodes and what not + // 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) @@ -241,32 +263,54 @@ export async function split(stylesheets: Stylesheet[]) { } } - // At this point, we probably created `{name}.utilities.css` files. If the - // original `{name}.css` is empty, then we can optimize the output a bit more - // by re-using the original file but just getting rid of the `layer - // (utilities)` marker. - // If removing files means that some `@import` at-rules are now unnecessary, we - // can also remove those. + // 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) { - continue let utilitySheet = utilitySheets.get(sheet) + + // This sheet was not split so there's nothing to do if (!utilitySheet) continue - if (sheet.root.toString().trim() !== '') 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. Point the imports back to the original file since we don't need the utility file anymore - for (let node of utilitySheet.importsFromParents) { + // 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') } // 3. Remove the original import from the non-utility sheet - // TODO: This does not work because we're cloning trees during the migration - // we *cannot* rely on reference semantics at all for any postcss nodes - for (let node of sheet.importsFromParents) { + for (let node of importNodes.get(sheet)) { node.remove() } diff --git a/packages/@tailwindcss-upgrade/src/stylesheet.ts b/packages/@tailwindcss-upgrade/src/stylesheet.ts index 1030229a1..7f3d2af10 100644 --- a/packages/@tailwindcss-upgrade/src/stylesheet.ts +++ b/packages/@tailwindcss-upgrade/src/stylesheet.ts @@ -1,14 +1,17 @@ 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: string + id: StylesheetId /** * The PostCSS AST that represents this stylesheet. @@ -37,24 +40,6 @@ export class Stylesheet { */ layers = new Set() - importsFromParents = new Set() - importsInSelf = new Set() - hasUtilities = false - - constructor(root: postcss.Root, file?: string) { - this.id = crypto.randomUUID() - this.root = root - this.file = file ?? null - } - - get ancestors() { - return walk(this, (sheet) => sheet.parents ?? []) - } - - get descendants() { - return walk(this, (sheet) => sheet.children ?? []) - } - static async load(filepath: string) { filepath = path.resolve(process.cwd(), filepath) @@ -73,6 +58,44 @@ export class Stylesheet { 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 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 { From 57a6e0aca7db6c068308c4e9af029f4f94f5c739 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 16:10:09 -0400 Subject: [PATCH 10/14] fix lol --- packages/@tailwindcss-upgrade/src/migrate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index e5e65100d..52ff34c03 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -306,7 +306,7 @@ export async function split(stylesheets: Stylesheet[]) { // 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') + node.params = node.params.replace(/\.utilities\.css(['"])/, '.css$1') } // 3. Remove the original import from the non-utility sheet From 311b8936aca45552bc040c1029598a68e325ce45 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 17:53:04 -0400 Subject: [PATCH 11/14] wip --- packages/@tailwindcss-upgrade/src/index.ts | 6 +++--- packages/@tailwindcss-upgrade/src/migrate.ts | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/index.ts b/packages/@tailwindcss-upgrade/src/index.ts index 99d6e133a..e85b55895 100644 --- a/packages/@tailwindcss-upgrade/src/index.ts +++ b/packages/@tailwindcss-upgrade/src/index.ts @@ -169,10 +169,10 @@ async function run() { } // Write all files to disk - // 1. Unlink all files that are no longer needed go first - // 2. Write them all sequentially for (let sheet of stylesheets) { - await fs.writeFile(sheet.file!, sheet.root!.toString()) + 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 52ff34c03..ca5eb6cc1 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -205,12 +205,18 @@ export async function split(stylesheets: Stylesheet[]) { // which likely means it points to `node_modules` if (!sheetId) continue - // 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 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 @@ -222,6 +228,7 @@ export async function split(stylesheets: Stylesheet[]) { params: `"${newFile}"`, raws: { after: '\n\n', + tailwind_destination_sheet_id: utilityDestination.id, }, }) @@ -230,15 +237,9 @@ export async function split(stylesheets: Stylesheet[]) { // 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) - - let utilityDestination = utilitySheets.get(originalDestination) - if (!utilityDestination) continue - - newImport.raws.tailwind_destination_sheet_id = utilityDestination.id } else { // This import will go immediately after the original import node.after(newImport) - newImport.raws.tailwind_destination_sheet_id = node.raws.tailwind_destination_sheet_id } } From 8dffc4c95f2b9cd8a487941e98c54388d4d28337 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 18:11:27 -0400 Subject: [PATCH 12/14] Fix tests --- .../migrate-at-layer-utilities.test.ts | 27 ++++++++++++++----- .../codemods/migrate-at-layer-utilities.ts | 2 +- 2 files changed, 22 insertions(+), 7 deletions(-) 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 02376ce71..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,17 +1,32 @@ import dedent from 'dedent' import postcss from 'postcss' import { describe, expect, it } from 'vitest' -import type { Stylesheet } from '../migrate' +import { Stylesheet } from '../stylesheet' import { formatNodes } from './format-nodes' import { migrateAtLayerUtilities } from './migrate-at-layer-utilities' const css = dedent -function migrate(stylesheet: Stylesheet | string) { - if (typeof stylesheet === 'string') { - stylesheet = { - content: stylesheet, - root: postcss.parse(stylesheet), +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) } } 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 644cec719..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,7 +1,7 @@ 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 type { Stylesheet } from '../migrate' +import { Stylesheet } from '../stylesheet' import { walk, WalkAction, walkDepth } from '../utils/walk' export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin { From f1418794acc440fa8bd5b5ef56f6861388b04d30 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 18:11:33 -0400 Subject: [PATCH 13/14] Update integration tests --- integrations/upgrade/index.test.ts | 142 ++++++++++++++++++----------- 1 file changed, 89 insertions(+), 53 deletions(-) diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 5b29f30f7..a833754e5 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -292,18 +292,17 @@ test( }, }, async ({ fs, exec }) => { - await exec('npx @tailwindcss/upgrade') + await exec('npx @tailwindcss/upgrade --force') await fs.expectFileToContain( 'src/index.css', css` @import 'tailwindcss'; - @import './utilities.css' layer(utilities); - @import './utilities.twupgrade.css'; + @import './utilities.css'; `, ) - await fs.expectFileNotToContain( + await fs.expectFileToContain( 'src/utilities.css', css` @utility no-scrollbar { @@ -315,19 +314,6 @@ test( } `, ) - - await fs.expectFileNotToContain( - 'src/utilities.twupgrade.css', - css` - @utility no-scrollbar { - &::-webkit-scrollbar { - display: none; - } - -ms-overflow-style: none; - scrollbar-width: none; - } - `, - ) }, ) @@ -412,42 +398,73 @@ test( 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';" + @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/a.utilities.css')).toMatchInlineSnapshot(` - " - @utility foo-from-import { + expect(await fs.read('src/utilities.utilities.css')).toMatchInlineSnapshot(` + "@utility foo-from-import { color: blue; - } - - @utility foo-from-a { - color: red; }" `) - expect(await fs.read('src/b.css')).toMatchInlineSnapshot(` - "@utility bar-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() - expect(await fs.read('src/c-2.css')).toMatchInlineSnapshot() - expect(await fs.read('src/c.utilities.css')).toMatchInlineSnapshot() - // await exec('npx @tailwindcss/cli -i src/index.css -o out.css') + 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('out.css')).toMatchInlineSnapshot() + 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; + }" + `) }, ) @@ -510,42 +527,48 @@ test( expect(await fs.read('src/index.css')).toMatchInlineSnapshot(` "@import 'tailwindcss/utilities' layer(utilities); @import './a.1.css' layer(utilities); - @import './a.1.utilities.css';" + @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(` - " - - @utility from-a-3 { - color: blue; - } - @utility from-a-2 { - color: green; - } + "@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.debug( +test( 'deeply nested imports', { fs: { @@ -614,45 +637,58 @@ test.debug( expect(await fs.read('src/index.css')).toMatchInlineSnapshot(` "@import 'tailwindcss/utilities' layer(utilities); - @import './a.1.css' 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); - @import "./a.2.utilities.css"; + #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(` - "@utility from-a-4 { - color: blue; - } - @utility from-a-3 { - color: blue; - } + "@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; + }" + `) }, ) From 719e6d9f8a0e0dc96a0fc8d2b957a709d460902f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 4 Oct 2024 18:20:45 -0400 Subject: [PATCH 14/14] Determine which config files each sheet uses --- packages/@tailwindcss-upgrade/src/migrate.ts | 18 ++++++++++++++++++ .../@tailwindcss-upgrade/src/stylesheet.ts | 15 +++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/@tailwindcss-upgrade/src/migrate.ts b/packages/@tailwindcss-upgrade/src/migrate.ts index ca5eb6cc1..b1d5b5f13 100644 --- a/packages/@tailwindcss-upgrade/src/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/migrate.ts @@ -125,6 +125,24 @@ export async function analyze(stylesheets: Stylesheet[]) { } } } + + // 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[]) { diff --git a/packages/@tailwindcss-upgrade/src/stylesheet.ts b/packages/@tailwindcss-upgrade/src/stylesheet.ts index 7f3d2af10..ce9833018 100644 --- a/packages/@tailwindcss-upgrade/src/stylesheet.ts +++ b/packages/@tailwindcss-upgrade/src/stylesheet.ts @@ -40,6 +40,11 @@ export class Stylesheet { */ 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) @@ -75,6 +80,16 @@ export class Stylesheet { 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() === '' }