Compare commits

...
Sign in to create a new pull request.

14 commits

Author SHA1 Message Date
Jordan Pittman
719e6d9f8a Determine which config files each sheet uses 2024-10-04 18:20:45 -04:00
Jordan Pittman
f1418794ac Update integration tests 2024-10-04 18:11:33 -04:00
Jordan Pittman
8dffc4c95f Fix tests 2024-10-04 18:11:27 -04:00
Jordan Pittman
311b8936ac wip 2024-10-04 17:53:04 -04:00
Jordan Pittman
57a6e0aca7 fix lol 2024-10-04 16:10:09 -04:00
Jordan Pittman
f387d88e46 wip 2024-10-04 16:08:58 -04:00
Jordan Pittman
0bb3e0863d Refactor 2024-10-04 14:33:56 -04:00
Jordan Pittman
cae3cc7ba2 wip 2024-10-04 13:17:26 -04:00
Jordan Pittman
ba93e02a4e wip 2024-10-04 13:17:14 -04:00
Jordan Pittman
6a0da6fa56 wip 2024-10-04 13:10:41 -04:00
Jordan Pittman
ca55a77c20 wip 2024-10-04 12:16:43 -04:00
Jordan Pittman
66a00ff842 wip 2024-10-04 11:26:12 -04:00
Jordan Pittman
87900fe5bc wip 2024-10-04 11:25:15 -04:00
Robin Malfait
bef1f724f9
WIP 2024-10-04 15:12:30 +02:00
8 changed files with 1296 additions and 108 deletions

View file

@ -1,3 +1,4 @@
import { expect } from 'vitest'
import { css, html, js, json, test } from '../utils'
test(
@ -261,3 +262,433 @@ test(
)
},
)
test(
'migrate utilities in an imported file',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/index.css': css`
@import 'tailwindcss';
@import './utilities.css' layer(utilities);
`,
'src/utilities.css': css`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade --force')
await fs.expectFileToContain(
'src/index.css',
css`
@import 'tailwindcss';
@import './utilities.css';
`,
)
await fs.expectFileToContain(
'src/utilities.css',
css`
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
`,
)
},
)
test(
'wip it',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/index.html': html`
<div class="hover:thing"></div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@import './a.css' layer(utilities);
@import './b.css' layer(components);
@import './c.css';
`,
'src/a.css': css`
@import './utilities.css';
.foo-from-a {
color: red;
}
`,
'src/utilities.css': css`
#foo {
--keep: me;
}
.foo-from-import {
color: blue;
}
`,
'src/b.css': css`
@import './components.css';
.bar-from-b {
color: red;
}
`,
'src/components.css': css`
.bar-from-import {
color: blue;
}
`,
'src/c.css': css`
@import './c-2.css' layer(utilities);
.baz-from-c {
color: green;
}
`,
'src/c-2.css': css`
@import './c-3.css';
#baz {
--keep: me;
}
.baz-from-import {
color: yellow;
}
`,
'src/c-3.css': css`
#baz {
--keep: me;
}
.baz-from-import {
color: yellow;
}
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.read('src/index.css')).toMatchInlineSnapshot(`
"@import 'tailwindcss/utilities' layer(utilities);
@import './a.css' layer(utilities);
@import "./a.utilities.css";
@import './b.css' layer(components);
@import "./b.utilities.css";
@import './c.css' layer(utilities);
@import "./c.utilities.css";"
`)
expect(await fs.read('src/a.css')).toMatchInlineSnapshot(`"@import './utilities.css'"`)
expect(await fs.read('src/a.utilities.css')).toMatchInlineSnapshot(`
"@import "./utilities.utilities.css";
@utility foo-from-a {
color: red;
}"
`)
expect(await fs.read('src/utilities.css')).toMatchInlineSnapshot(`
"#foo {
--keep: me;
}"
`)
expect(await fs.read('src/utilities.utilities.css')).toMatchInlineSnapshot(`
"@utility foo-from-import {
color: blue;
}"
`)
expect(await fs.read('src/b.css')).toMatchInlineSnapshot(`""`)
expect(await fs.read('src/b.utilities.css')).toMatchInlineSnapshot(`
"@import "./components.css";
@utility bar-from-b {
color: red;
}"
`)
expect(await fs.read('src/c.css')).toMatchInlineSnapshot(`
"@import './c-2.css' layer(utilities);
.baz-from-c {
color: green;
}"
`)
expect(await fs.read('src/c.utilities.css')).toMatchInlineSnapshot(
`"@import "./c-2.utilities.css""`,
)
expect(await fs.read('src/c-2.css')).toMatchInlineSnapshot(`
"@import './c-3.css';
#baz {
--keep: me;
}"
`)
expect(await fs.read('src/c-2.utilities.css')).toMatchInlineSnapshot(`
"@import "./c-3.utilities.css";
@utility baz-from-import {
color: yellow;
}"
`)
expect(await fs.read('src/c-3.css')).toMatchInlineSnapshot(`
"#baz {
--keep: me;
}"
`)
expect(await fs.read('src/c-3.utilities.css')).toMatchInlineSnapshot(`
"@utility baz-from-import {
color: yellow;
}"
`)
},
)
test(
'deeply nested imports',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/index.html': html`
<div class="hover:thing"></div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@import './a.1.css' layer(utilities);
`,
'src/a.1.css': css`
@import './a.2.css';
#from-a-1 {
--keep: me;
}
.from-a-1 {
color: red;
}
`,
'src/a.2.css': css`
@import './a.3.css';
#from-a-2 {
--keep: me;
}
.from-a-2 {
color: green;
}
`,
'src/a.3.css': css`
#from-a-3 {
--keep: me;
}
.from-a-3 {
color: blue;
}
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.read('src/index.css')).toMatchInlineSnapshot(`
"@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(utilities);
@import "./a.1.utilities.css";"
`)
expect(await fs.read('src/a.1.css')).toMatchInlineSnapshot(`
"@import './a.2.css';
#from-a-1 {
--keep: me;
}"
`)
expect(await fs.read('src/a.1.utilities.css')).toMatchInlineSnapshot(`
"@import "./a.2.utilities.css";
@utility from-a-1 {
color: red;
}"
`)
expect(await fs.read('src/a.2.css')).toMatchInlineSnapshot(`
"@import './a.3.css';
#from-a-2 {
--keep: me;
}"
`)
expect(await fs.read('src/a.2.utilities.css')).toMatchInlineSnapshot(`
"@import "./a.3.utilities.css";
@utility from-a-2 {
color: green;
}"
`)
expect(await fs.read('src/a.3.css')).toMatchInlineSnapshot(`
"#from-a-3 {
--keep: me;
}"
`)
expect(await fs.read('src/a.3.utilities.css')).toMatchInlineSnapshot(`
"@utility from-a-3 {
color: blue;
}"
`)
},
)
test(
'deeply nested imports',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/index.html': html`
<div class="hover:thing"></div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@import './a.1.css';
`,
'src/a.1.css': css`
@import './a.2.css' layer(utilities);
#from-a-1 {
--keep: me;
}
.from-a-1 {
color: red;
}
`,
'src/a.2.css': css`
@import './a.3.css';
#from-a-2 {
--keep: me;
}
.from-a-2 {
color: green;
}
`,
'src/a.3.css': css`
@import './a.4.css';
#from-a-3 {
--keep: me;
}
.from-a-3 {
color: blue;
}
`,
'src/a.4.css': css`
#from-a-4 {
--keep: me;
}
.from-a-4 {
color: blue;
}
`,
},
},
async ({ fs, exec }) => {
await exec('npx @tailwindcss/upgrade --force')
expect(await fs.read('src/index.css')).toMatchInlineSnapshot(`
"@import 'tailwindcss/utilities' layer(utilities);
@import './a.1.css' layer(utilities);
@import "./a.1.utilities.css";"
`)
expect(await fs.read('src/a.1.css')).toMatchInlineSnapshot(`
"@import './a.2.css' layer(utilities);
#from-a-1 {
--keep: me;
}
.from-a-1 {
color: red;
}"
`)
expect(await fs.read('src/a.1.utilities.css')).toMatchInlineSnapshot(
`"@import "./a.2.utilities.css""`,
)
expect(await fs.read('src/a.2.css')).toMatchInlineSnapshot(`
"@import './a.3.css';
#from-a-2 {
--keep: me;
}"
`)
expect(await fs.read('src/a.2.utilities.css')).toMatchInlineSnapshot(`
"@import "./a.3.utilities.css";
@utility from-a-2 {
color: green;
}"
`)
expect(await fs.read('src/a.3.css')).toMatchInlineSnapshot(`
"@import './a.4.css';
#from-a-3 {
--keep: me;
}"
`)
expect(await fs.read('src/a.3.utilities.css')).toMatchInlineSnapshot(`
"@import "./a.4.utilities.css";
@utility from-a-3 {
color: blue;
}"
`)
expect(await fs.read('src/a.4.css')).toMatchInlineSnapshot(`
"#from-a-4 {
--keep: me;
}"
`)
expect(await fs.read('src/a.4.utilities.css')).toMatchInlineSnapshot(`
"@utility from-a-4 {
color: blue;
}"
`)
},
)

View file

@ -1,16 +1,39 @@
import dedent from 'dedent'
import postcss from 'postcss'
import { describe, expect, it } from 'vitest'
import { Stylesheet } from '../stylesheet'
import { formatNodes } from './format-nodes'
import { migrateAtLayerUtilities } from './migrate-at-layer-utilities'
const css = dedent
function migrate(input: string) {
async function migrate(
data:
| Stylesheet
| string
| {
root: postcss.Root
layers?: string[]
},
) {
let stylesheet: Stylesheet
if (typeof data === 'string') {
stylesheet = await Stylesheet.fromString(data)
} else if (data instanceof Stylesheet) {
stylesheet = data
} else {
stylesheet = await Stylesheet.fromRoot(data.root)
for (let layer of data.layers ?? []) {
stylesheet.layers.add(layer)
}
}
return postcss()
.use(migrateAtLayerUtilities())
.use(migrateAtLayerUtilities(stylesheet))
.use(formatNodes())
.process(input, { from: expect.getState().testPath })
.process(stylesheet.root!, { from: expect.getState().testPath })
.then((result) => result.css)
}
@ -820,3 +843,213 @@ it('should not lose attribute selectors', async () => {
}"
`)
})
describe('layered stylesheets', () => {
it('should transform classes to utilities inside a layered stylesheet (utilities)', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
`)
})
it('should transform classes to utilities inside a layered stylesheet (components)', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
`),
layers: ['components'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
`)
})
it('should NOT transform classes to utilities inside a non-utility, layered stylesheet', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
`),
layers: ['foo'],
}),
).toMatchInlineSnapshot(`
"/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}"
`)
})
it('should handle non-classes in utility-layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
#main {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"
#main {
color: red;
}
@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
`)
})
it('should handle non-classes in utility-layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
@layer utilities {
@layer utilities {
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}
}
/* Utility #2 */
.bar {
/* Declarations: */
color: red;
}
#main {
color: red;
}
}
/* Utility #3 */
.baz {
/* Declarations: */
color: red;
}
#secondary {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@layer utilities {
#main {
color: red;
}
}
#secondary {
color: red;
}
@utility foo {
@layer utilities {
@layer utilities {
/* Utility #1 */
/* Declarations: */
color: red;
}
}
}
@utility bar {
@layer utilities {
/* Utility #2 */
/* Declarations: */
color: red;
}
}
@utility baz {
/* Utility #3 */
/* Declarations: */
color: red;
}"
`)
})
it('imports are preserved in layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
@import 'thing';
.foo {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@import 'thing';
@utility foo {
color: red;
}"
`)
})
it('charset is preserved in layered stylesheets', async () => {
expect(
await migrate({
root: postcss.parse(css`
@charset "utf-8";
.foo {
color: red;
}
`),
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@charset "utf-8";
@utility foo {
color: red;
}"
`)
})
})

View file

@ -1,9 +1,10 @@
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
import SelectorParser from 'postcss-selector-parser'
import { segment } from '../../../tailwindcss/src/utils/segment'
import { Stylesheet } from '../stylesheet'
import { walk, WalkAction, walkDepth } from '../utils/walk'
export function migrateAtLayerUtilities(): Plugin {
export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
function migrate(atRule: AtRule) {
// Only migrate `@layer utilities` and `@layer components`.
if (atRule.params !== 'utilities' && atRule.params !== 'components') return
@ -86,6 +87,12 @@ export function migrateAtLayerUtilities(): Plugin {
clones.push(clone)
walk(clone, (node) => {
if (node.type === 'atrule') {
if (!node.nodes || node.nodes?.length === 0) {
node.remove()
}
}
if (node.type !== 'rule') return
// Fan out each utility into its own rule.
@ -186,7 +193,7 @@ export function migrateAtLayerUtilities(): Plugin {
// Mark the node as pretty so that it gets formatted by Prettier later.
clone.raws.tailwind_pretty = true
clone.raws.before += '\n\n'
clone.raws.before = `${clone.raws.before ?? ''}\n\n`
}
// Cleanup
@ -259,7 +266,16 @@ export function migrateAtLayerUtilities(): Plugin {
return {
postcssPlugin: '@tailwindcss/upgrade/migrate-at-layer-utilities',
OnceExit: (root) => {
OnceExit: (root, { atRule }) => {
let isUtilityStylesheet =
stylesheet.layers?.has('utilities') || stylesheet.layers?.has('components')
if (isUtilityStylesheet) {
let rule = atRule({ name: 'layer', params: 'utilities' })
rule.append(root.nodes)
root.append(rule)
}
// Migrate `@layer utilities` and `@layer components` into `@utility`.
// Using this instead of the visitor API in case we want to use
// postcss-nesting in the future.
@ -282,6 +298,17 @@ export function migrateAtLayerUtilities(): Plugin {
}
})
}
// If the stylesheet is inside a layered import then we can remove the top-level layer directive we added
if (isUtilityStylesheet) {
root.each((node) => {
if (node.type !== 'atrule') return
if (node.name !== 'layer') return
if (node.params !== 'utilities') return
node.replaceWith(node.nodes ?? [])
})
}
},
}
}

View file

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

View file

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

View file

@ -1,11 +1,20 @@
#!/usr/bin/env node
import { globby } from 'globby'
import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import type { Config } from 'tailwindcss'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { formatNodes } from './codemods/format-nodes'
import { help } from './commands/help'
import { migrate as migrateStylesheet } from './migrate'
import {
analyze as analyzeStylesheets,
migrate as migrateStylesheet,
split as splitStylesheets,
type MigrateOptions,
} from './migrate'
import { Stylesheet } from './stylesheet'
import { migrate as migrateTemplate } from './template/migrate'
import { parseConfig } from './template/parseConfig'
import { args, type Arg } from './utils/args'
@ -109,17 +118,63 @@ async function run() {
// Ensure we are only dealing with CSS files
files = files.filter((file) => file.endsWith('.css'))
// Analyze the stylesheets
let loadResults = await Promise.allSettled(files.map((filepath) => Stylesheet.load(filepath)))
// Load and parse all stylesheets
for (let result of loadResults) {
if (result.status === 'rejected') {
error(`${result.reason}`)
}
}
let stylesheets = loadResults
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value)
// Analyze the stylesheets
try {
await analyzeStylesheets(stylesheets)
} catch (e: unknown) {
error(`${e}`)
}
// Migrate each file
await Promise.allSettled(
files.map((file) =>
migrateStylesheet(file, {
newPrefix: parsedConfig?.newPrefix ?? undefined,
designSystem: parsedConfig?.designSystem,
userConfig: parsedConfig?.userConfig,
}),
),
let options: MigrateOptions = {
newPrefix: parsedConfig?.newPrefix ?? undefined,
designSystem: parsedConfig?.designSystem,
userConfig: parsedConfig?.userConfig,
}
let migrateResults = await Promise.allSettled(
stylesheets.map((sheet) => migrateStylesheet(sheet, options)),
)
for (let result of migrateResults) {
if (result.status === 'rejected') {
error(`${result.reason}`)
}
}
// Split up stylesheets (as needed)
try {
await splitStylesheets(stylesheets)
} catch (e: unknown) {
error(`${e}`)
}
// Format nodes
for (let sheet of stylesheets) {
await postcss([formatNodes()]).process(sheet.root!, { from: sheet.file! })
}
// Write all files to disk
for (let sheet of stylesheets) {
if (!sheet.file) continue
await fs.writeFile(sheet.file, sheet.root.toString())
}
success('Stylesheet migration complete.')
}

View file

@ -1,13 +1,16 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import postcss from 'postcss'
import type { Config } from 'tailwindcss'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { formatNodes } from './codemods/format-nodes'
import { DefaultMap } from '../../tailwindcss/src/utils/default-map'
import { segment } from '../../tailwindcss/src/utils/segment'
import { migrateAtApply } from './codemods/migrate-at-apply'
import { migrateAtLayerUtilities } from './codemods/migrate-at-layer-utilities'
import { migrateMissingLayers } from './codemods/migrate-missing-layers'
import { migrateTailwindDirectives } from './codemods/migrate-tailwind-directives'
import { Stylesheet, type StylesheetId } from './stylesheet'
import { resolveCssId } from './utils/resolve'
import { walk, WalkAction } from './utils/walk'
export interface MigrateOptions {
newPrefix?: string
@ -15,20 +18,342 @@ export interface MigrateOptions {
userConfig?: Config
}
export async function migrateContents(contents: string, options: MigrateOptions, file?: string) {
export async function migrateContents(
stylesheet: Stylesheet | string,
options: MigrateOptions = {},
) {
if (typeof stylesheet === 'string') {
stylesheet = await Stylesheet.fromString(stylesheet)
}
return postcss()
.use(migrateAtApply(options))
.use(migrateAtLayerUtilities())
.use(migrateAtLayerUtilities(stylesheet))
.use(migrateMissingLayers())
.use(migrateTailwindDirectives(options))
.use(formatNodes())
.process(contents, { from: file })
.then((result) => result.css)
.process(stylesheet.root, { from: stylesheet.file ?? undefined })
}
export async function migrate(file: string, options: MigrateOptions) {
let fullPath = path.resolve(process.cwd(), file)
let contents = await fs.readFile(fullPath, 'utf-8')
export async function migrate(stylesheet: Stylesheet, options: MigrateOptions) {
if (!stylesheet.file) {
throw new Error('Cannot migrate a stylesheet without a file path')
}
await fs.writeFile(fullPath, await migrateContents(contents, options, fullPath))
await migrateContents(stylesheet, options)
}
export async function analyze(stylesheets: Stylesheet[]) {
let stylesheetsByFile = new Map<string, Stylesheet>()
for (let sheet of stylesheets) {
if (sheet.file) {
stylesheetsByFile.set(sheet.file, sheet)
}
}
// Step 1: Record which `@import` rules point to which stylesheets
// and which stylesheets are parents/children of each other
let processor = postcss([
{
postcssPlugin: 'mark-import-nodes',
AtRule: {
import(node) {
// Find what the import points to
let id = node.params.match(/['"](.*)['"]/)?.[1]
if (!id) return
let basePath = node.source?.input.file
? path.dirname(node.source.input.file)
: process.cwd()
// Resolve the import to a file path
let resolvedPath: string | false
try {
resolvedPath = resolveCssId(id, basePath)
} catch (err) {
console.warn(`Failed to resolve import: ${id}. Skipping.`)
console.error(err)
return
}
if (!resolvedPath) return
// Find the stylesheet pointing to the resolved path
let stylesheet = stylesheetsByFile.get(resolvedPath)
// If it _does not_ exist in stylesheets we don't care and skip it
// this is likely because its in node_modules or a workspace package
// that we don't want to modify
if (!stylesheet) return
// Mark the import node with the ID of the stylesheet it points to
// We will use these later to build lookup tables and modify the AST
node.raws.tailwind_destination_sheet_id = stylesheet.id
let parent = node.source?.input.file
? stylesheetsByFile.get(node.source.input.file)
: undefined
// Connect sheets together in a dependency graph
if (parent) {
stylesheet.parents.add(parent)
parent.children.add(stylesheet)
}
for (let part of segment(node.params, ' ')) {
if (!part.startsWith('layer(')) continue
if (!part.endsWith(')')) continue
stylesheet.layers.add(part.slice(6, -1).trim())
}
},
},
},
])
for (let sheet of stylesheets) {
if (!sheet.file) continue
await processor.process(sheet.root, { from: sheet.file })
}
// Step 2: Analyze the AST so each stylesheet can know what layers it is inside
for (let sheet of stylesheets) {
for (let ancestor of sheet.ancestors) {
for (let layer of ancestor.layers) {
sheet.layers.add(layer)
}
}
}
// Step 3: Determine which config files are used by each stylesheet (if any)
for (let sheet of stylesheets) {
for (let node of sheet.configRules) {
let configPath = node.params.match(/['"](.*)['"]/)?.[1]
if (!configPath) continue
sheet.configFiles.add(configPath)
}
}
for (let sheet of stylesheets) {
for (let parent of sheet.ancestors) {
for (let file of parent.configFiles) {
sheet.configFiles.add(file)
}
}
}
}
export async function split(stylesheets: Stylesheet[]) {
let stylesheetsById = new Map<StylesheetId, Stylesheet>()
for (let sheet of stylesheets) {
stylesheetsById.set(sheet.id, sheet)
}
// A list of stylesheets we've created specifically to hold `@utility` rules
let containsUtilities = new Set<Stylesheet>()
// Keep track of sheets that contain `@utillity` rules
for (let sheet of stylesheets) {
let isLayered = sheet.layers.has('utilities') || sheet.layers.has('components')
if (!isLayered) continue
walk(sheet.root, (node) => {
if (node.type !== 'atrule') return
if (node.name !== 'utility') return
containsUtilities.add(sheet)
return WalkAction.Stop
})
}
// Split every imported stylesheet into two parts
// - one with the utilities and one without
let utilitySheets = new Map<Stylesheet, Stylesheet>()
for (let sheet of stylesheets) {
// Ignore stylesheets that were not imported
if (!sheet.file) continue
if (sheet.parents.size === 0) continue
// Skip stylesheets that don't have utilities
// and don't have any children that have utilities
if (!containsUtilities.has(sheet)) {
if (!Array.from(sheet.descendants).some((child) => containsUtilities.has(child))) {
continue
}
}
let utilities = postcss.root({
raws: {
tailwind_pretty: true,
},
})
walk(sheet.root, (node) => {
if (node.type !== 'atrule') return
if (node.name !== 'utility') return
// `append` will move this node from the original sheet
// to the new utilities sheet
utilities.append(node)
return WalkAction.Skip
})
let utilitySheet = await Stylesheet.fromRoot(
utilities,
sheet.file.replace(/\.css$/, '.utilities.css'),
)
utilitySheets.set(sheet, utilitySheet)
stylesheetsById.set(utilitySheet.id, utilitySheet)
}
for (let sheet of stylesheets) {
let utilitySheet = utilitySheets.get(sheet)
let utilityImports: Set<postcss.AtRule> = new Set()
for (let node of sheet.importRules) {
let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined
// This import rule does not point to a stylesheet
// which likely means it points to `node_modules`
if (!sheetId) continue
let originalDestination = stylesheetsById.get(sheetId)
// This import points to a stylesheet that no longer exists which likely
// means it was removed by the optimizer this will be cleaned up later
if (!originalDestination) continue
let utilityDestination = utilitySheets.get(originalDestination)
// A utility sheet doesn't exist for this import so it doesn't need
// to be processed
if (!utilityDestination) continue
let id = node.params.match(/['"](.*)['"]/)?.[1]
if (!id) return
let newFile = id.replace(/\.css$/, '.utilities.css')
// The import will just point to the new file without any media queries,
// layers, or other conditions because `@utility` MUST be top-level.
let newImport = node.clone({
params: `"${newFile}"`,
raws: {
after: '\n\n',
tailwind_destination_sheet_id: utilityDestination.id,
},
})
if (utilitySheet) {
// If this import is intended to go into the utility sheet
// we'll collect it into a list to add later. If we don't'
// we'll end up adding them in reverse order.
utilityImports.add(newImport)
} else {
// This import will go immediately after the original import
node.after(newImport)
}
}
// Add imports to the top of the utility sheet if necessary
if (utilitySheet && utilityImports.size > 0) {
utilitySheet.root.prepend(Array.from(utilityImports))
}
}
// Make sure the utility sheets are linked to one another
for (let [normalSheet, utilitySheet] of utilitySheets) {
for (let parent of normalSheet.parents) {
let utilityParent = utilitySheets.get(parent)
if (!utilityParent) continue
utilitySheet.parents.add(utilityParent)
}
for (let child of normalSheet.children) {
let utilityChild = utilitySheets.get(child)
if (!utilityChild) continue
utilitySheet.children.add(utilityChild)
}
}
// Tracks the at rules that import a given stylesheet
let importNodes = new DefaultMap<Stylesheet, Set<postcss.AtRule>>(() => new Set())
for (let sheet of stylesheetsById.values()) {
for (let node of sheet.importRules) {
let sheetId = node.raws.tailwind_destination_sheet_id as StylesheetId | undefined
// This import rule does not point to a stylesheet
if (!sheetId) continue
let destination = stylesheetsById.get(sheetId)
// This import rule does not point to a stylesheet that exists
// We'll remove it later
if (!destination) continue
importNodes.get(destination).add(node)
}
}
// At this point we've created many `{name}.utilities.css` files.
// If the original file _becomes_ empty after splitting that means that
// dedicated utility file is not required and we can move the utilities
// back to the original file.
//
// This could be done in one step but separating them makes it easier to
// reason about since the stylesheets are in a consistent state before we
// perform any cleanup tasks.
for (let sheet of stylesheets) {
let utilitySheet = utilitySheets.get(sheet)
// This sheet was not split so there's nothing to do
if (!utilitySheet) continue
// This sheet did not become empty
if (!sheet.isEmpty) continue
// We have a sheet that became empty after splitting
// 1. Replace the sheet with it's utility sheet content
sheet.root = utilitySheet.root
// 2. Rewrite imports in parent sheets to point to the original sheet
for (let node of importNodes.get(utilitySheet)) {
node.params = node.params.replace(/\.utilities\.css(['"])/, '.css$1')
}
// 3. Remove the original import from the non-utility sheet
for (let node of importNodes.get(sheet)) {
node.remove()
}
// 3. Mark the utility sheet for removal
utilitySheets.delete(sheet)
}
stylesheets.push(...utilitySheets.values())
}
// @import './a.css' layer(utilities) ;
// -> @utility { … }
// @import './a.css' layer(utilities);
// -> @import './b.css';
// -> @import './c.css';
// -> .utility-class
// -> #main
// -> other stuff
// -> other stuff
// @import './a.css' layer(utilities);
// -> @import './b.css'; (layers: utilities)
// -> @import './c.css';
// -> @import './d.css';
// -> #main
// -> other stuff

View file

@ -0,0 +1,124 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as util from 'node:util'
import * as postcss from 'postcss'
export type StylesheetId = string
export class Stylesheet {
/**
* A unique identifier for this stylesheet
*
* Used to track the stylesheet in PostCSS nodes.
*/
id: StylesheetId
/**
* The PostCSS AST that represents this stylesheet.
*/
root: postcss.Root
/**
* The path to the file that this stylesheet was loaded from.
*
* If this stylesheet was not loaded from a file this will be `null`.
*/
file: string | null = null
/**
* Stylesheets that import this stylesheet.
*/
parents = new Set<Stylesheet>()
/**
* Stylesheets that are imported by stylesheet.
*/
children = new Set<Stylesheet>()
/**
* The layers this stylesheet is in, even transitive layers from parents.
*/
layers = new Set<string>()
/**
* The list of config files that this stylesheet uses
*/
configFiles = new Set<string>()
static async load(filepath: string) {
filepath = path.resolve(process.cwd(), filepath)
let css = await fs.readFile(filepath, 'utf-8')
let root = postcss.parse(css, { from: filepath })
return new Stylesheet(root, filepath)
}
static async fromString(css: string) {
let root = postcss.parse(css)
return new Stylesheet(root)
}
static async fromRoot(root: postcss.Root, file?: string) {
return new Stylesheet(root, file)
}
constructor(root: postcss.Root, file?: string) {
this.id = Math.random().toString(36).slice(2)
this.root = root
this.file = file ?? null
}
get importRules() {
let imports = new Set<postcss.AtRule>()
this.root.walkAtRules('import', (rule) => {
imports.add(rule)
})
return imports
}
get configRules() {
let rules = new Set<postcss.AtRule>()
this.root.walkAtRules('config', (rule) => {
rules.add(rule)
})
return rules
}
get isEmpty() {
return this.root.toString().trim() === ''
}
get ancestors() {
return walk<Stylesheet>(this, (sheet) => sheet.parents ?? [])
}
get descendants() {
return walk<Stylesheet>(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<T>(value: T, getList: (value: T) => Iterable<T>, seen = new Set<T>()): Iterable<T> {
for (let item of getList(value)) {
if (seen.has(item)) continue
seen.add(item)
yield item
yield* walk(item, getList, seen)
}
}