Make candidate template migrations faster (#18025)

This PR makes the migrations for templates much faster. To make this
work, I also had to move things around a bit (so you might want to check
this PR commit by commit). I also solved an issue by restructuring the
code.

### Performance

For starters, we barely applied any caching when migrating candidates
from α to β. The problem with this is that in big projects the same
candidates will appear _everywhere_, so caching is going to be useful
here.

One of the reasons why we didn't do any caching is that some migrations
were checking if a migration is actually safe to do. To do this, we were
checking the `location` (the location of the candidate in the template).
Since this location is unique for each template, caching was not
possible.

So the first order of business was to hoist the `isSafeMigration` check
up as the very first thing we do in the migration.

If we do this first, then the only remaining code relies on the
`DesignSystem`, `UserConfig` and `rawCandidate`.

In a project, the `DesignSystem` and `UserConfig` will be the same
during the migration, only the `rawCandidate` will be different which
means that we can move all this logic in a good old `DefaultMap` and
cache the heck out of it.

Running the numbers on our Tailwind Plus repo, this results in:
```
    Total seen candidates: 2 211 844
Total migrated candidates: 7 775 
               Cache hits: 1 575 700
```

That's a lot of work we _don't_ have to do. Looking at the timings, the
template migration step goes from ~45s to ~10s because of this.

Another big benefit of this is that this makes migrations _actually_
safe. Before we were checking if a migration was safe to do in specific
migrations. But other migrations were still printing the candidate which
could still result in an unsafe migration.

For example when migrating the `blur` and the `shadow` classes, the
`isSafeMigration` was used. But if the input was `!flex` then the safety
check wasn't even checked in this specific migration.

### Safe migrations

Also made some changes to the `isSafeMigration` logic itself. We used to
start by checking the location, but thinking about the problem again,
the actual big problem we were running into is classes that are short
like `blur`, and `shadow` because they could be used in other contexts
than a Tailwind CSS class.

Inverting this logic means that more specific Tailwind CSS classes will
very likely _not_ cause any issues at all.

For example:
- If you have variants: `hover:focus:flex`
- If you have arbitrary properties: `[color:red]`
- If you have arbitrary values: `bg-[red]`
- If you have a modifier: `bg-red-500/50`
- If you have a `-` in the name: `bg-red-500`

Even better if we can't parse a candidate at all, we can skip the
migrations all together.

This brings us to the issue in #17974, one of the issues was already
solved by just hoisting the `isSafeMigration`. But to make the issue was
completely solved I also made sure that in Vue attributes like
`:active="…"` are also considered unsafe (note: `:class` is allowed).

Last but not least, in case of the `!duration` that got replaced with
`duration!` was solved by verifying that the candidate actually produces
valid CSS. We can compute the signature for this class.

The reason this wasn't thrown away earlier is because we can correctly
parse `duration` but `duration` on its own doesn't exist,
`duration-<number>` does exist as a functional utility which is why it
parsed in the first place.

Fixes: #17974

## Test plan

1. Ran the tool on our Tailwind UI Templates repo to compare the new
output with the "old" behavior and there were no differences in output.
2. Ran the tool on our Tailwind Plus repo, and the template migration
step went from ~45s to ~10s.
3. Added additional tests to verify the issues in #17974 are fixed.


[ci-all] let's run this on all CI platforms...
This commit is contained in:
Robin Malfait 2025-05-15 13:17:39 +02:00 committed by GitHub
parent 6fb98d2f40
commit 1ada8e0f22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 243 additions and 235 deletions

View File

@ -17,11 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure that running the Standalone build does not leave temporary files behind ([#17981](https://github.com/tailwindlabs/tailwindcss/pull/17981))
- Fix `-rotate-*` utilities with arbitrary values ([#18014](https://github.com/tailwindlabs/tailwindcss/pull/18014))
- Upgrade: Change casing of utilities with named values to kebab-case to match updated theme variables ([#18017](https://github.com/tailwindlabs/tailwindcss/pull/18017))
- Upgrade: Fix unsafe migrations in Vue files ([#18025](https://github.com/tailwindlabs/tailwindcss/pull/18025))
- Ignore custom variants using `:merge(…)` selectors in legacy JS plugins ([#18020](https://github.com/tailwindlabs/tailwindcss/pull/18020))
### Added
- Upgrade: Migrate bare values to named values ([#18000](https://github.com/tailwindlabs/tailwindcss/pull/18000))
- Upgrade: Make candidate template migrations faster using caching ([#18025](https://github.com/tailwindlabs/tailwindcss/pull/18025))
## [4.1.6] - 2025-05-09

View File

@ -151,7 +151,7 @@ test(
<div
class="[letter-spacing:theme(letterSpacing.superWide)] [line-height:theme(lineHeight.superLoose)]"
></div>
<div class="text-red-superRed/superOpaque leading-superLoose"></div>
<div class="text-superRed/superOpaque leading-superLoose"></div>
`,
'node_modules/my-external-lib/src/template.html': html`
<div class="text-red-500">
@ -169,7 +169,7 @@ test(
<div
class="[letter-spacing:var(--tracking-super-wide)] [line-height:var(--leading-super-loose)]"
></div>
<div class="text-red-super-red/super-opaque leading-super-loose"></div>
<div class="text-super-red/super-opaque leading-super-loose"></div>
--- src/input.css ---
@import 'tailwindcss';

View File

@ -0,0 +1,61 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { expect, test, vi } from 'vitest'
import * as versions from '../../utils/version'
import { migrateCandidate } from './migrate'
vi.spyOn(versions, 'isMajor').mockReturnValue(true)
test('does not replace classes in invalid positions', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
async function shouldNotReplace(example: string, candidate = '!border') {
expect(
await migrateCandidate(designSystem, {}, candidate, {
contents: example,
start: example.indexOf(candidate),
end: example.indexOf(candidate) + candidate.length,
}),
).toEqual(candidate)
}
await shouldNotReplace(`let notBorder = !border \n`)
await shouldNotReplace(`{ "foo": !border.something + ""}\n`)
await shouldNotReplace(`<div v-if="something && !border"></div>\n`)
await shouldNotReplace(`<div v-else-if="something && !border"></div>\n`)
await shouldNotReplace(`<div v-show="something && !border"></div>\n`)
await shouldNotReplace(`<div v-if="!border || !border"></div>\n`)
await shouldNotReplace(`<div v-else-if="!border || !border"></div>\n`)
await shouldNotReplace(`<div v-show="!border || !border"></div>\n`)
await shouldNotReplace(`<div v-if="!border"></div>\n`)
await shouldNotReplace(`<div v-else-if="!border"></div>\n`)
await shouldNotReplace(`<div v-show="!border"></div>\n`)
await shouldNotReplace(`<div x-if="!border"></div>\n`)
await shouldNotReplace(`let notShadow = shadow \n`, 'shadow')
await shouldNotReplace(`{ "foo": shadow.something + ""}\n`, 'shadow')
await shouldNotReplace(`<div v-if="something && shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-else-if="something && shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-show="something && shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-if="shadow || shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-else-if="shadow || shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-show="shadow || shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-if="shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-else-if="shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div v-show="shadow"></div>\n`, 'shadow')
await shouldNotReplace(`<div x-if="shadow"></div>\n`, 'shadow')
await shouldNotReplace(
`<div style={{filter: 'drop-shadow(30px 10px 4px #4444dd)'}}/>\n`,
'shadow',
)
// Next.js Image placeholder cases
await shouldNotReplace(`<Image placeholder="blur" src="/image.jpg" />`, 'blur')
await shouldNotReplace(`<Image placeholder={'blur'} src="/image.jpg" />`, 'blur')
await shouldNotReplace(`<Image placeholder={blur} src="/image.jpg" />`, 'blur')
// https://github.com/tailwindlabs/tailwindcss/issues/17974
await shouldNotReplace('<div v-if="!duration">', '!duration')
await shouldNotReplace('<div :active="!duration">', '!duration')
await shouldNotReplace('<div :active="!visible">', '!visible')
})

View File

@ -1,3 +1,7 @@
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import * as version from '../../utils/version'
const QUOTES = ['"', "'", '`']
const LOGICAL_OPERATORS = ['&&', '||', '?', '===', '==', '!=', '!==', '>', '>=', '<', '<=']
const CONDITIONAL_TEMPLATE_SYNTAX = [
@ -5,6 +9,7 @@ const CONDITIONAL_TEMPLATE_SYNTAX = [
/v-else-if=['"]$/,
/v-if=['"]$/,
/v-show=['"]$/,
/(?<!:?class)=['"]$/,
// Alpine
/x-if=['"]$/,
@ -12,7 +17,68 @@ const CONDITIONAL_TEMPLATE_SYNTAX = [
]
const NEXT_PLACEHOLDER_PROP = /placeholder=\{?['"]$/
export function isSafeMigration(location: { contents: string; start: number; end: number }) {
export function isSafeMigration(
rawCandidate: string,
location: { contents: string; start: number; end: number },
designSystem: DesignSystem,
): boolean {
let [candidate] = Array.from(parseCandidate(rawCandidate, designSystem))
// If we can't parse the candidate, then it's not a candidate at all. However,
// we could be dealing with legacy classes like `tw__flex` in Tailwind CSS v3
// land, which also wouldn't parse.
//
// So let's only skip if we couldn't parse and we are not in Tailwind CSS v3.
//
if (!candidate && version.isGreaterThan(3)) {
return true
}
// Parsed a candidate succesfully, verify if it's a valid candidate
else if (candidate) {
// When we have variants, we can assume that the candidate is safe to migrate
// because that requires colons.
//
// E.g.: `hover:focus:flex`
if (candidate.variants.length > 0) {
return true
}
// When we have an arbitrary property, the candidate has such a particular
// structure it's very likely to be safe.
//
// E.g.: `[color:red]`
if (candidate.kind === 'arbitrary') {
return true
}
// A static candidate is very likely safe if it contains a dash.
//
// E.g.: `items-center`
if (candidate.kind === 'static' && candidate.root.includes('-')) {
return true
}
// A functional candidate is very likely safe if it contains a value (which
// implies a `-`). Or if the root contains a dash.
//
// E.g.: `bg-red-500`, `bg-position-20`
if (
(candidate.kind === 'functional' && candidate.value !== null) ||
(candidate.kind === 'functional' && candidate.root.includes('-'))
) {
return true
}
// If the candidate contains a modifier, it's very likely to be safe because
// it implies that it contains a `/`.
//
// E.g.: `text-sm/7`
if (candidate.kind === 'functional' && candidate.modifier) {
return true
}
}
let currentLineBeforeCandidate = ''
for (let i = location.start - 1; i >= 0; i--) {
let char = location.contents.at(i)!

View File

@ -51,27 +51,6 @@ export function migrateArbitraryUtilities(
continue
}
// 1. Canonicalize the value. This might be a bit wasteful because it might
// have been done by other migrations before, but essentially we want to
// canonicalize the arbitrary value to its simplest canonical form. We
// won't be constant folding `calc(…)` expressions (yet?), but we can
// remove unnecessary whitespace (which the `printCandidate` already
// handles for us).
//
// E.g.:
//
// ```
// [display:_flex_] => [display:flex]
// [display:_flex] => [display:flex]
// [display:flex_] => [display:flex]
// [display:flex] => [display:flex]
// ```
//
let canonicalizedCandidate = designSystem.printCandidate(readonlyCandidate)
if (canonicalizedCandidate !== rawCandidate) {
return migrateArbitraryUtilities(designSystem, _userConfig, canonicalizedCandidate)
}
// The below logic makes use of mutation. Since candidates in the
// DesignSystem are cached, we can't mutate them directly.
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>

View File

@ -0,0 +1,27 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { expect, test, vi } from 'vitest'
import * as versions from '../../utils/version'
import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate'
vi.spyOn(versions, 'isMajor').mockReturnValue(true)
test.each([
// Normalize whitespace in arbitrary properties
['[display:flex]', '[display:flex]'],
['[display:_flex]', '[display:flex]'],
['[display:flex_]', '[display:flex]'],
['[display:_flex_]', '[display:flex]'],
// Normalize whitespace in `calc` expressions
['w-[calc(100%-2rem)]', 'w-[calc(100%-2rem)]'],
['w-[calc(100%_-_2rem)]', 'w-[calc(100%-2rem)]'],
// Normalize the important modifier
['!flex', 'flex!'],
['flex!', 'flex!'],
])('%s => %s', async (candidate, result) => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(migrateCanonicalizeCandidate(designSystem, {}, candidate)).toEqual(result)
})

View File

@ -0,0 +1,29 @@
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
// Canonicalize the value to its minimal form. This will normalize whitespace,
// and print the important modifier `!` in the correct place.
//
// E.g.:
//
// ```
// [display:_flex_] => [display:flex]
// [display:_flex] => [display:flex]
// [display:flex_] => [display:flex]
// [display:flex] => [display:flex]
// ```
//
export function migrateCanonicalizeCandidate(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
) {
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
let canonicalizedCandidate = designSystem.printCandidate(readonlyCandidate)
if (canonicalizedCandidate !== rawCandidate) {
return canonicalizedCandidate
}
}
return rawCandidate
}

View File

@ -1,68 +0,0 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { expect, test } from 'vitest'
import { migrateImportant } from './migrate-important'
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(
migrateImportant(designSystem, {}, candidate, {
contents: `"${candidate}"`,
start: 1,
end: candidate.length + 1,
}),
).toEqual(result)
})
test('does not match false positives', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
expect(
migrateImportant(designSystem, {}, '!border', {
contents: `let notBorder = !border\n`,
start: 16,
end: 16 + '!border'.length,
}),
).toEqual('!border')
})
test('does not replace classes in invalid positions', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
function shouldNotReplace(example: string, candidate = '!border') {
expect(
migrateImportant(designSystem, {}, candidate, {
contents: example,
start: example.indexOf(candidate),
end: example.indexOf(candidate) + candidate.length,
}),
).toEqual(candidate)
}
shouldNotReplace(`let notBorder = !border \n`)
shouldNotReplace(`{ "foo": !border.something + ""}\n`)
shouldNotReplace(`<div v-if="something && !border"></div>\n`)
shouldNotReplace(`<div v-else-if="something && !border"></div>\n`)
shouldNotReplace(`<div v-show="something && !border"></div>\n`)
shouldNotReplace(`<div v-if="!border || !border"></div>\n`)
shouldNotReplace(`<div v-else-if="!border || !border"></div>\n`)
shouldNotReplace(`<div v-show="!border || !border"></div>\n`)
shouldNotReplace(`<div v-if="!border"></div>\n`)
shouldNotReplace(`<div v-else-if="!border"></div>\n`)
shouldNotReplace(`<div v-show="!border"></div>\n`)
shouldNotReplace(`<div x-if="!border"></div>\n`)
})

View File

@ -1,48 +0,0 @@
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { isSafeMigration } from './is-safe-migration'
// 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(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
location?: {
contents: string
start: number
end: number
},
): string {
nextCandidate: for (let candidate of parseCandidate(rawCandidate, designSystem)) {
if (candidate.important && candidate.raw[candidate.raw.length - 1] !== '!') {
// The important migration is one of the most broad migrations with a high
// potential of matching false positives since `!` is a valid character in
// most programming languages. Since v4 is technically backward compatible
// with v3 in that it can read `!` in the front of the utility too, we err
// on the side of caution and only migrate candidates that we are certain
// are inside of a string.
if (location && !isSafeMigration(location)) {
continue nextCandidate
}
// 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 designSystem.printCandidate(candidate)
}
}
return rawCandidate
}

View File

@ -43,38 +43,3 @@ test.each([
expect(await migrateLegacyClasses(designSystem, {}, candidate)).toEqual(result)
})
test('does not replace classes in invalid positions', async () => {
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
base: __dirname,
})
async function shouldNotReplace(example: string, candidate = 'shadow') {
expect(
await migrateLegacyClasses(designSystem, {}, candidate, {
contents: example,
start: example.indexOf(candidate),
end: example.indexOf(candidate) + candidate.length,
}),
).toEqual(candidate)
}
await shouldNotReplace(`let notShadow = shadow \n`)
await shouldNotReplace(`{ "foo": shadow.something + ""}\n`)
await shouldNotReplace(`<div v-if="something && shadow"></div>\n`)
await shouldNotReplace(`<div v-else-if="something && shadow"></div>\n`)
await shouldNotReplace(`<div v-show="something && shadow"></div>\n`)
await shouldNotReplace(`<div v-if="shadow || shadow"></div>\n`)
await shouldNotReplace(`<div v-else-if="shadow || shadow"></div>\n`)
await shouldNotReplace(`<div v-show="shadow || shadow"></div>\n`)
await shouldNotReplace(`<div v-if="shadow"></div>\n`)
await shouldNotReplace(`<div v-else-if="shadow"></div>\n`)
await shouldNotReplace(`<div v-show="shadow"></div>\n`)
await shouldNotReplace(`<div x-if="shadow"></div>\n`)
await shouldNotReplace(`<div style={{filter: 'drop-shadow(30px 10px 4px #4444dd)'}}/>\n`)
// Next.js Image placeholder cases
await shouldNotReplace(`<Image placeholder="blur" src="/image.jpg" />`, 'blur')
await shouldNotReplace(`<Image placeholder={'blur'} src="/image.jpg" />`, 'blur')
await shouldNotReplace(`<Image placeholder={blur} src="/image.jpg" />`, 'blur')
})

View File

@ -7,7 +7,6 @@ import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import * as version from '../../utils/version'
import { baseCandidate } from './candidates'
import { isSafeMigration } from './is-safe-migration'
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@ -72,11 +71,6 @@ export async function migrateLegacyClasses(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
location?: {
contents: string
start: number
end: number
},
): Promise<string> {
// These migrations are only safe when migrating from v3 to v4.
//
@ -111,7 +105,6 @@ export async function migrateLegacyClasses(
newCandidate.important = candidate.important
yield [
candidate,
newCandidate,
THEME_KEYS.get(baseCandidateString),
THEME_KEYS.get(newBaseCandidateString),
@ -119,34 +112,7 @@ export async function migrateLegacyClasses(
}
}
for (let [fromCandidate, toCandidate, fromThemeKey, toThemeKey] of migrate(rawCandidate)) {
// Every utility that has a simple representation (e.g.: `blur`, `radius`,
// etc.`) without variants or special characters _could_ be a potential
// problem during the migration.
let isPotentialProblematicClass = (() => {
if (fromCandidate.variants.length > 0) {
return false
}
if (fromCandidate.kind === 'arbitrary') {
return false
}
if (fromCandidate.kind === 'static') {
return !fromCandidate.root.includes('-')
}
if (fromCandidate.kind === 'functional') {
return fromCandidate.value === null || !fromCandidate.root.includes('-')
}
return false
})()
if (location && isPotentialProblematicClass && !isSafeMigration(location)) {
continue
}
for (let [toCandidate, fromThemeKey, toThemeKey] of migrate(rawCandidate)) {
if (fromThemeKey && toThemeKey) {
// Migrating something that resolves to a value in the theme.
let customFrom = designSystem.resolveThemeValue(fromThemeKey, true)

View File

@ -1,3 +1,4 @@
import { decl } from '../../../../tailwindcss/src/ast'
import { parseCandidate, type Candidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
@ -16,8 +17,18 @@ export function migratePrefix(
if (!version.isMajor(3)) return rawCandidate
if (!seenDesignSystems.has(designSystem)) {
designSystem.utilities.functional('group', () => null)
designSystem.utilities.functional('peer', () => null)
designSystem.utilities.functional('group', (value) => [
// To ensure that `@apply group` works when computing a signature
decl('--phantom-class', 'group'),
// To ensure `group` and `group/foo` are considered different classes
decl('--phantom-modifier', value.modifier?.value),
])
designSystem.utilities.functional('peer', (value) => [
// To ensure that `@apply peer` works when computing a signature
decl('--phantom-class', 'peer'),
// To ensure `peer` and `peer/foo` are considered different classes
decl('--phantom-modifier', value.modifier?.value),
])
seenDesignSystems.add(designSystem)
}

View File

@ -1,10 +1,11 @@
import fs from 'node:fs/promises'
import path, { extname } from 'node:path'
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string'
import { extractRawCandidates } from './candidates'
import { isSafeMigration } from './is-safe-migration'
import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities'
import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value'
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
@ -12,9 +13,9 @@ import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection'
import { migrateBareValueUtilities } from './migrate-bare-utilities'
import { migrateBgGradient } from './migrate-bg-gradient'
import { migrateCamelcaseInNamedValue } from './migrate-camelcase-in-named-value'
import { migrateCanonicalizeCandidate } from './migrate-canonicalize-candidate'
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values'
import { migrateImportant } from './migrate-important'
import { migrateLegacyArbitraryValues } from './migrate-legacy-arbitrary-values'
import { migrateLegacyClasses } from './migrate-legacy-classes'
import { migrateMaxWidthScreen } from './migrate-max-width-screen'
@ -24,22 +25,18 @@ import { migratePrefix } from './migrate-prefix'
import { migrateSimpleLegacyClasses } from './migrate-simple-legacy-classes'
import { migrateThemeToVar } from './migrate-theme-to-var'
import { migrateVariantOrder } from './migrate-variant-order'
import { computeUtilitySignature } from './signatures'
export type Migration = (
designSystem: DesignSystem,
userConfig: Config | null,
rawCandidate: string,
location?: {
contents: string
start: number
end: number
},
) => string | Promise<string>
export const DEFAULT_MIGRATIONS: Migration[] = [
migrateEmptyArbitraryValues,
migratePrefix,
migrateImportant,
migrateCanonicalizeCandidate,
migrateBgGradient,
migrateSimpleLegacyClasses,
migrateCamelcaseInNamedValue,
@ -58,6 +55,29 @@ export const DEFAULT_MIGRATIONS: Migration[] = [
migrateOptimizeModifier,
]
let migrateCached = new DefaultMap<
DesignSystem,
DefaultMap<Config | null, DefaultMap<string, Promise<string>>>
>((designSystem) => {
return new DefaultMap((userConfig) => {
return new DefaultMap(async (rawCandidate) => {
let original = rawCandidate
for (let migration of DEFAULT_MIGRATIONS) {
rawCandidate = await migration(designSystem, userConfig, rawCandidate)
}
// Verify that the candidate actually makes sense at all. E.g.: `duration`
// is not a valid candidate, but it will parse because `duration-<number>`
// exists.
let signature = computeUtilitySignature.get(designSystem).get(rawCandidate)
if (typeof signature !== 'string') return original
return rawCandidate
})
})
})
export async function migrateCandidate(
designSystem: DesignSystem,
userConfig: Config | null,
@ -69,23 +89,12 @@ export async function migrateCandidate(
end: number
},
): Promise<string> {
let original = rawCandidate
for (let migration of DEFAULT_MIGRATIONS) {
rawCandidate = await migration(designSystem, userConfig, rawCandidate, location)
// Skip this migration if we think that the migration is unsafe
if (location && !isSafeMigration(rawCandidate, location, designSystem)) {
return rawCandidate
}
// If nothing changed, let's parse it again and re-print it. This will migrate
// pretty print candidates to the new format. If it did change, we already had
// to re-print it.
//
// E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)`
if (rawCandidate === original) {
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
return designSystem.printCandidate(candidate)
}
}
return rawCandidate
return migrateCached.get(designSystem).get(userConfig).get(rawCandidate)
}
export default async function migrateContents(

View File

@ -11,6 +11,15 @@ export function isMajor(version: number) {
return semver.satisfies(installedTailwindVersion(), `>=${version}.0.0 <${version + 1}.0.0`)
}
/**
* Must be of greater than the current major version including minor and patch.
*
* E.g.: `isGreaterThan(3)`
*/
export function isGreaterThan(version: number) {
return semver.gte(installedTailwindVersion(), `${version + 1}.0.0`)
}
let cache = new DefaultMap((base) => {
let tailwindVersion = getPackageVersionSync('tailwindcss', base)
if (!tailwindVersion) throw new Error('Tailwind CSS is not installed')