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
```
This commit is contained in:
Philipp Spiess 2024-09-27 17:52:58 +02:00 committed by GitHub
parent b16444fc3a
commit 58edf8e7fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 94 additions and 172 deletions

View File

@ -1,6 +1,6 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { describe, expect, test } from 'vitest'
import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates'
import { extractRawCandidates, printCandidate, replaceCandidateInContent } from './candidates'
let html = String.raw
@ -10,107 +10,40 @@ test('extracts candidates with positions from a template', async () => {
<button class="bg-blue-500 text-white">My button</button>
</div>
`
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(extractCandidates(designSystem, content)).resolves.toMatchInlineSnapshot(`
let candidates = await extractRawCandidates(content)
let validCandidates = candidates.filter(
({ rawCandidate }) => designSystem.parseCandidate(rawCandidate).length > 0,
)
expect(validCandidates).toMatchInlineSnapshot(`
[
{
"candidate": {
"important": false,
"kind": "functional",
"modifier": null,
"negative": false,
"raw": "bg-blue-500",
"root": "bg",
"value": {
"fraction": null,
"kind": "named",
"value": "blue-500",
},
"variants": [],
},
"end": 28,
"rawCandidate": "bg-blue-500",
"start": 17,
},
{
"candidate": {
"important": false,
"kind": "functional",
"modifier": null,
"negative": false,
"raw": "hover:focus:text-white",
"root": "text",
"value": {
"fraction": null,
"kind": "named",
"value": "white",
},
"variants": [
{
"compounds": true,
"kind": "static",
"root": "focus",
},
{
"compounds": true,
"kind": "static",
"root": "hover",
},
],
},
"end": 51,
"rawCandidate": "hover:focus:text-white",
"start": 29,
},
{
"candidate": {
"important": false,
"kind": "arbitrary",
"modifier": null,
"property": "color",
"raw": "[color:red]",
"value": "red",
"variants": [],
},
"end": 63,
"rawCandidate": "[color:red]",
"start": 52,
},
{
"candidate": {
"important": false,
"kind": "functional",
"modifier": null,
"negative": false,
"raw": "bg-blue-500",
"root": "bg",
"value": {
"fraction": null,
"kind": "named",
"value": "blue-500",
},
"variants": [],
},
"end": 98,
"rawCandidate": "bg-blue-500",
"start": 87,
},
{
"candidate": {
"important": false,
"kind": "functional",
"modifier": null,
"negative": false,
"raw": "text-white",
"root": "text",
"value": {
"fraction": null,
"kind": "named",
"value": "white",
},
"variants": [],
},
"end": 109,
"rawCandidate": "text-white",
"start": 99,
},
]
@ -127,7 +60,11 @@ test('replaces the right positions for a candidate', async () => {
base: __dirname,
})
let candidate = (await extractCandidates(designSystem, content))[0]
let candidates = await extractRawCandidates(content)
let candidate = candidates.find(
({ rawCandidate }) => designSystem.parseCandidate(rawCandidate).length > 0,
)!
expect(replaceCandidateInContent(content, 'flex', candidate.start, candidate.end))
.toMatchInlineSnapshot(`

View File

@ -1,20 +1,16 @@
import { Scanner } from '@tailwindcss/oxide'
import stringByteSlice from 'string-byte-slice'
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
export async function extractCandidates(
designSystem: DesignSystem,
export async function extractRawCandidates(
content: string,
): Promise<{ candidate: Candidate; start: number; end: number }[]> {
): Promise<{ rawCandidate: string; start: number; end: number }[]> {
let scanner = new Scanner({})
let result = scanner.getCandidatesWithPositions({ content, extension: 'html' })
let candidates: { candidate: Candidate; start: number; end: number }[] = []
let candidates: { rawCandidate: string; start: number; end: number }[] = []
for (let { candidate: rawCandidate, position: start } of result) {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
candidates.push({ candidate, start, end: start + rawCandidate.length })
}
candidates.push({ rawCandidate, start, end: start + rawCandidate.length })
}
return candidates
}

View File

@ -1,6 +1,5 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { expect, test } from 'vitest'
import { printCandidate } from '../candidates'
import { bgGradient } from './bg-gradient'
test.each([
@ -19,6 +18,5 @@ test.each([
base: __dirname,
})
let migrated = bgGradient(designSystem.parseCandidate(candidate)[0]!)
expect(migrated ? printCandidate(migrated) : migrated).toEqual(result)
expect(bgGradient(designSystem, candidate)).toEqual(result)
})

View File

@ -1,17 +1,20 @@
import type { Candidate } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from '../candidates'
const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl']
export function bgGradient(candidate: Candidate): Candidate | null {
if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) {
let direction = candidate.root.slice(15)
export function bgGradient(designSystem: DesignSystem, rawCandidate: string): string {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) {
let direction = candidate.root.slice(15)
if (!DIRECTIONS.includes(direction)) {
return null
if (!DIRECTIONS.includes(direction)) {
continue
}
candidate.root = `bg-linear-to-${direction}`
return printCandidate(candidate)
}
candidate.root = `bg-linear-to-${direction}`
return candidate
}
return null
return rawCandidate
}

View File

@ -0,0 +1,22 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import dedent from 'dedent'
import { expect, test } from 'vitest'
import { important } from './important'
let html = dedent
test.each([
['!flex', 'flex!'],
['min-[calc(1000px+12em)]:!flex', 'min-[calc(1000px_+_12em)]:flex!'],
['md:!block', 'md:block!'],
// Does not change non-important candidates
['bg-blue-500', 'bg-blue-500'],
['min-[calc(1000px+12em)]:flex', 'min-[calc(1000px+12em)]:flex'],
])('%s => %s', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(important(designSystem, candidate)).toEqual(result)
})

View File

@ -0,0 +1,27 @@
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { printCandidate } from '../candidates'
// In v3 the important modifier `!` sits in front of the utility itself, not
// before any of the variants. In v4, we want it to be at the end of the utility
// so that it's always in the same location regardless of whether you used
// variants or not.
//
// So this:
//
// !flex md:!block
//
// Should turn into:
//
// flex! md:block!
export function important(designSystem: DesignSystem, rawCandidate: string): string {
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
// The printCandidate function will already put the exclamation mark in
// the right place, so we just need to mark this candidate as requiring a
// migration.
return printCandidate(candidate)
}
}
return rawCandidate
}

View File

@ -1,37 +0,0 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import dedent from 'dedent'
import { expect, test } from 'vitest'
import migrate from '../migrate'
import { migrateImportant } from './migrate-important'
let html = dedent
test('applies the migration', async () => {
let content = html`
<div class="bg-blue-500 !flex min-[calc(1000px+12em)]:!flex">
<button class="md:!block">My button</button>
</div>
`
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(`
"<div class="bg-blue-500 flex! min-[calc(1000px_+_12em)]:flex!">
<button class="md:block!">My button</button>
</div>"
`)
})
test('does not migrate if the exclamation mark is already at the end', async () => {
let content = html` <div class="min-[calc(1000px+12em)]:flex!"></div> `
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(migrate(designSystem, content, [migrateImportant])).resolves.toMatchInlineSnapshot(`
"<div class="min-[calc(1000px+12em)]:flex!"></div>"
`)
})

View File

@ -1,23 +0,0 @@
import type { Candidate } from '../../../../tailwindcss/src/candidate'
// In v3 the important modifier `!` sits in front of the utility itself, not
// before any of the variants. In v4, we want it to be at the end of the utility
// so that it's always in the same location regardless of whether you used
// variants or not.
//
// So this:
//
// !flex md:!block
//
// Should turn into:
//
// flex! md:block!
export function migrateImportant(candidate: Candidate): Candidate | null {
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
// The printCandidate function will already put the exclamation mark in the
// right place, so we just need to mark this candidate as requiring a
// migration.
return candidate
}
return null
}

View File

@ -1,36 +1,35 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Candidate } from '../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../tailwindcss/src/design-system'
import { extractCandidates, printCandidate, replaceCandidateInContent } from './candidates'
import { extractRawCandidates, replaceCandidateInContent } from './candidates'
import { bgGradient } from './codemods/bg-gradient'
import { migrateImportant } from './codemods/migrate-important'
import { important } from './codemods/important'
export type Migration = (candidate: Candidate) => Candidate | null
export type Migration = (designSystem: DesignSystem, rawCandidate: string) => string
export default async function migrateContents(
designSystem: DesignSystem,
contents: string,
migrations: Migration[] = [migrateImportant, bgGradient],
migrations: Migration[] = [important, bgGradient],
): Promise<string> {
let candidates = await extractCandidates(designSystem, contents)
let candidates = await extractRawCandidates(contents)
// Sort candidates by starting position desc
candidates.sort((a, z) => z.start - a.start)
let output = contents
for (let { candidate, start, end } of candidates) {
for (let { rawCandidate, start, end } of candidates) {
let needsMigration = false
for (let migration of migrations) {
let migrated = migration(candidate)
if (migrated) {
candidate = migrated
let candidate = migration(designSystem, rawCandidate)
if (rawCandidate !== candidate) {
rawCandidate = candidate
needsMigration = true
}
}
if (needsMigration) {
output = replaceCandidateInContent(output, printCandidate(candidate), start, end)
output = replaceCandidateInContent(output, rawCandidate, start, end)
}
}