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