Skip to content

Migrate utilities in CSS files imported into layers #14617

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
461 changes: 374 additions & 87 deletions integrations/upgrade/index.test.ts

Large diffs are not rendered by default.

28 changes: 27 additions & 1 deletion integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface TestContext {
write(filePath: string, content: string): Promise<void>
read(filePath: string): Promise<string>
glob(pattern: string): Promise<[string, string][]>
dumpFiles(pattern: string): Promise<string>
expectFileToContain(
filePath: string,
contents: string | string[] | RegExp | RegExp[],
Expand Down Expand Up @@ -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())
}
},
)
Expand Down Expand Up @@ -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 || '<EMPTY>'}`)
.join('\n\n')}`
},
async expectFileToContain(filePath, contents) {
return retryAssertion(async () => {
let fileContent = await this.read(filePath)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

Expand Down Expand Up @@ -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;
}"
`)
})
})
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 ?? [])
})
}
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
Loading