Skip to content

Commit 58edf8e

Browse files
Update template migration interface (#14539)
This PR lands a quick interface update for template migration with some lessons learned form our existing migrations. Specifically, this version attempts to: - Allow migrations to access the raw candidate. This way we can migrate candidates that _would not parse as valid in v4_. This will help us migrate prefixes in candidates from v3 to v4. - There is no more awkward "return null" if nothing has changed. The return `null` was necessary because we relied on mutating the Variant and since parsing/printing could remove some information, it was not easy to find out wether a candidate needed to be migrated at all. With a string though, we can do this cheaply by returning the `rawCandidate`. - We previously asserted that if `parseCandidate` returns more than one candidate, we only picked the first one. This behavior is now moved into the migrations where we have more context. For now though, we still do not need to worry about this since in all cases, these duplicate candidates would serialize to the same `Candidate`. It is helpful if you only want to run a migration on a specific type of candidate (e.g. if there's a `static` one and a more generic `functional` one). - We need access to the `DesignSystem` inside migrations now to be able to `parseCandidate`s. Opening this up as a separate PR since it can take some time to iron out the edge cases for the individual codemod PRs and I don't want to be rebasing all the time. ## Before ```ts type Migration = (candidate: Candidate) => Candidate | null ``` ## After ```ts type Migration = (designSystem: DesignSystem, rawCandidate: string) => string ```
1 parent b16444f commit 58edf8e

File tree

9 files changed

+94
-172
lines changed

9 files changed

+94
-172
lines changed

packages/@tailwindcss-upgrade/src/template/candidates.test.ts

Lines changed: 17 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
22
import { describe, expect, test } from 'vitest'
3-
import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates'
3+
import { extractRawCandidates, printCandidate, replaceCandidateInContent } from './candidates'
44

55
let html = String.raw
66

@@ -10,107 +10,40 @@ test('extracts candidates with positions from a template', async () => {
1010
<button class="bg-blue-500 text-white">My button</button>
1111
</div>
1212
`
13-
1413
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
1514
base: __dirname,
1615
})
1716

18-
expect(extractCandidates(designSystem, content)).resolves.toMatchInlineSnapshot(`
17+
let candidates = await extractRawCandidates(content)
18+
let validCandidates = candidates.filter(
19+
({ rawCandidate }) => designSystem.parseCandidate(rawCandidate).length > 0,
20+
)
21+
22+
expect(validCandidates).toMatchInlineSnapshot(`
1923
[
2024
{
21-
"candidate": {
22-
"important": false,
23-
"kind": "functional",
24-
"modifier": null,
25-
"negative": false,
26-
"raw": "bg-blue-500",
27-
"root": "bg",
28-
"value": {
29-
"fraction": null,
30-
"kind": "named",
31-
"value": "blue-500",
32-
},
33-
"variants": [],
34-
},
3525
"end": 28,
26+
"rawCandidate": "bg-blue-500",
3627
"start": 17,
3728
},
3829
{
39-
"candidate": {
40-
"important": false,
41-
"kind": "functional",
42-
"modifier": null,
43-
"negative": false,
44-
"raw": "hover:focus:text-white",
45-
"root": "text",
46-
"value": {
47-
"fraction": null,
48-
"kind": "named",
49-
"value": "white",
50-
},
51-
"variants": [
52-
{
53-
"compounds": true,
54-
"kind": "static",
55-
"root": "focus",
56-
},
57-
{
58-
"compounds": true,
59-
"kind": "static",
60-
"root": "hover",
61-
},
62-
],
63-
},
6430
"end": 51,
31+
"rawCandidate": "hover:focus:text-white",
6532
"start": 29,
6633
},
6734
{
68-
"candidate": {
69-
"important": false,
70-
"kind": "arbitrary",
71-
"modifier": null,
72-
"property": "color",
73-
"raw": "[color:red]",
74-
"value": "red",
75-
"variants": [],
76-
},
7735
"end": 63,
36+
"rawCandidate": "[color:red]",
7837
"start": 52,
7938
},
8039
{
81-
"candidate": {
82-
"important": false,
83-
"kind": "functional",
84-
"modifier": null,
85-
"negative": false,
86-
"raw": "bg-blue-500",
87-
"root": "bg",
88-
"value": {
89-
"fraction": null,
90-
"kind": "named",
91-
"value": "blue-500",
92-
},
93-
"variants": [],
94-
},
9540
"end": 98,
41+
"rawCandidate": "bg-blue-500",
9642
"start": 87,
9743
},
9844
{
99-
"candidate": {
100-
"important": false,
101-
"kind": "functional",
102-
"modifier": null,
103-
"negative": false,
104-
"raw": "text-white",
105-
"root": "text",
106-
"value": {
107-
"fraction": null,
108-
"kind": "named",
109-
"value": "white",
110-
},
111-
"variants": [],
112-
},
11345
"end": 109,
46+
"rawCandidate": "text-white",
11447
"start": 99,
11548
},
11649
]
@@ -127,7 +60,11 @@ test('replaces the right positions for a candidate', async () => {
12760
base: __dirname,
12861
})
12962

130-
let candidate = (await extractCandidates(designSystem, content))[0]
63+
let candidates = await extractRawCandidates(content)
64+
65+
let candidate = candidates.find(
66+
({ rawCandidate }) => designSystem.parseCandidate(rawCandidate).length > 0,
67+
)!
13168

13269
expect(replaceCandidateInContent(content, 'flex', candidate.start, candidate.end))
13370
.toMatchInlineSnapshot(`

packages/@tailwindcss-upgrade/src/template/candidates.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
import { Scanner } from '@tailwindcss/oxide'
22
import stringByteSlice from 'string-byte-slice'
33
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
4-
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
54

6-
export async function extractCandidates(
7-
designSystem: DesignSystem,
5+
export async function extractRawCandidates(
86
content: string,
9-
): Promise<{ candidate: Candidate; start: number; end: number }[]> {
7+
): Promise<{ rawCandidate: string; start: number; end: number }[]> {
108
let scanner = new Scanner({})
119
let result = scanner.getCandidatesWithPositions({ content, extension: 'html' })
1210

13-
let candidates: { candidate: Candidate; start: number; end: number }[] = []
11+
let candidates: { rawCandidate: string; start: number; end: number }[] = []
1412
for (let { candidate: rawCandidate, position: start } of result) {
15-
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
16-
candidates.push({ candidate, start, end: start + rawCandidate.length })
17-
}
13+
candidates.push({ rawCandidate, start, end: start + rawCandidate.length })
1814
}
1915
return candidates
2016
}

packages/@tailwindcss-upgrade/src/template/codemods/bg-gradient.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
22
import { expect, test } from 'vitest'
3-
import { printCandidate } from '../candidates'
43
import { bgGradient } from './bg-gradient'
54

65
test.each([
@@ -19,6 +18,5 @@ test.each([
1918
base: __dirname,
2019
})
2120

22-
let migrated = bgGradient(designSystem.parseCandidate(candidate)[0]!)
23-
expect(migrated ? printCandidate(migrated) : migrated).toEqual(result)
21+
expect(bgGradient(designSystem, candidate)).toEqual(result)
2422
})
Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import type { Candidate } from '../../../../tailwindcss/src/candidate'
1+
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
2+
import { printCandidate } from '../candidates'
23

34
const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl']
45

5-
export function bgGradient(candidate: Candidate): Candidate | null {
6-
if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) {
7-
let direction = candidate.root.slice(15)
6+
export function bgGradient(designSystem: DesignSystem, rawCandidate: string): string {
7+
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
8+
if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) {
9+
let direction = candidate.root.slice(15)
810

9-
if (!DIRECTIONS.includes(direction)) {
10-
return null
11-
}
11+
if (!DIRECTIONS.includes(direction)) {
12+
continue
13+
}
1214

13-
candidate.root = `bg-linear-to-${direction}`
14-
return candidate
15+
candidate.root = `bg-linear-to-${direction}`
16+
return printCandidate(candidate)
17+
}
1518
}
16-
return null
19+
return rawCandidate
1720
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
2+
import dedent from 'dedent'
3+
import { expect, test } from 'vitest'
4+
import { important } from './important'
5+
6+
let html = dedent
7+
8+
test.each([
9+
['!flex', 'flex!'],
10+
['min-[calc(1000px+12em)]:!flex', 'min-[calc(1000px_+_12em)]:flex!'],
11+
['md:!block', 'md:block!'],
12+
13+
// Does not change non-important candidates
14+
['bg-blue-500', 'bg-blue-500'],
15+
['min-[calc(1000px+12em)]:flex', 'min-[calc(1000px+12em)]:flex'],
16+
])('%s => %s', async (candidate, result) => {
17+
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
18+
base: __dirname,
19+
})
20+
21+
expect(important(designSystem, candidate)).toEqual(result)
22+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
2+
import { printCandidate } from '../candidates'
3+
4+
// In v3 the important modifier `!` sits in front of the utility itself, not
5+
// before any of the variants. In v4, we want it to be at the end of the utility
6+
// so that it's always in the same location regardless of whether you used
7+
// variants or not.
8+
//
9+
// So this:
10+
//
11+
// !flex md:!block
12+
//
13+
// Should turn into:
14+
//
15+
// flex! md:block!
16+
export function important(designSystem: DesignSystem, rawCandidate: string): string {
17+
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
18+
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
19+
// The printCandidate function will already put the exclamation mark in
20+
// the right place, so we just need to mark this candidate as requiring a
21+
// migration.
22+
return printCandidate(candidate)
23+
}
24+
}
25+
26+
return rawCandidate
27+
}

packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.test.ts

Lines changed: 0 additions & 37 deletions
This file was deleted.

packages/@tailwindcss-upgrade/src/template/codemods/migrate-important.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

packages/@tailwindcss-upgrade/src/template/migrate.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,35 @@
11
import fs from 'node:fs/promises'
22
import path from 'node:path'
3-
import type { Candidate } from '../../../tailwindcss/src/candidate'
43
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
5-
import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates'
4+
import { extractRawCandidates, replaceCandidateInContent } from './candidates'
65
import { bgGradient } from './codemods/bg-gradient'
7-
import { migrateImportant } from './codemods/migrate-important'
6+
import { important } from './codemods/important'
87

9-
export type Migration = (candidate: Candidate) => Candidate | null
8+
export type Migration = (designSystem: DesignSystem, rawCandidate: string) => string
109

1110
export default async function migrateContents(
1211
designSystem: DesignSystem,
1312
contents: string,
14-
migrations: Migration[] = [migrateImportant, bgGradient],
13+
migrations: Migration[] = [important, bgGradient],
1514
): Promise<string> {
16-
let candidates = await extractCandidates(designSystem, contents)
15+
let candidates = await extractRawCandidates(contents)
1716

1817
// Sort candidates by starting position desc
1918
candidates.sort((a, z) => z.start - a.start)
2019

2120
let output = contents
22-
for (let { candidate, start, end } of candidates) {
21+
for (let { rawCandidate, start, end } of candidates) {
2322
let needsMigration = false
2423
for (let migration of migrations) {
25-
let migrated = migration(candidate)
26-
if (migrated) {
27-
candidate = migrated
24+
let candidate = migration(designSystem, rawCandidate)
25+
if (rawCandidate !== candidate) {
26+
rawCandidate = candidate
2827
needsMigration = true
2928
}
3029
}
3130

3231
if (needsMigration) {
33-
output = replaceCandidateInContent(output, printCandidate(candidate), start, end)
32+
output = replaceCandidateInContent(output, rawCandidate, start, end)
3433
}
3534
}
3635

0 commit comments

Comments
 (0)