mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Design system driven upgrade migrations (#17831)
This PR introduces a vastly improved upgrade migrations system, to migrate your codebase and modernize your utilities to make use of the latest variants and utilities. It all started when I saw this PR the other day: https://github.com/tailwindlabs/tailwindcss/pull/17790 I was about to comment "Don't forget to add a migration". But I've been thinking about a system where we can automate this process away. This PR introduces this system. This PR introduces upgrade migrations based on the internal Design System, and it mainly updates arbitrary variants, arbitrary properties and arbitrary values. ## The problem Whenever we ship new utilities, or you make changes to your CSS file by introducing new `@theme` values, or adding new `@utility` rules. It could be that the rest of your codebase isn't aware of that, but you could be using these values. For example, it could be that you have a lot of arbitrary properties in your codebase, they look something like this: ```html <div class="[color-scheme:dark] [text-wrap:balance]"></div> ``` Whenever we introduce new features in Tailwind CSS, you probably don't keep an eye on the release notes and update all of these arbitrary properties to the newly introduced utilities. But with this PR, we can run the upgrade tool: ```console npx -y @tailwindcss/upgrade@latest ``` ...and it will upgrade your project to use the new utilities: ```html <div class="scheme-dark text-balance"></div> ``` It also works for arbitrary values, for example imagine you have classes like this: ```html <!-- Arbitrary property --> <div class="[max-height:1lh]"></div> <!-- Arbitrary value --> <div class="max-h-[1lh]"></div> ``` Running the upgrade tool again: ```console npx -y @tailwindcss/upgrade@latest ``` ... gives you the following output: ```html <!-- Arbitrary property --> <div class="max-h-lh"></div> <!-- Arbitrary value --> <div class="max-h-lh"></div> ``` This is because of the original PR I mentioned, which introduced the `max-h-lh` utilities. A nice benefit is that this output only has 1 unique class instead of 2, which also potentially reduces the size of your CSS file. It could also be that you are using arbitrary values where you (or a team member) didn't even know an alternative solution existed. E.g.: ```html <div class="w-[48rem]"></div> ``` After running the upgrade tool you will get this: ```html <div class="w-3xl"></div> ``` We can go further though. Since the release of Tailwind CSS v4, we introduced the concept of "bare values". Essentially allowing you to type a number on utilities where it makes sense, and we produce a value based on that number. So an input like this: ```html <div class="border-[123px]"></div> ``` Will be optimized to just: ```html <div class="border-123"></div> ``` This can be very useful for complex utilities, for example, how many times have you written something like this: ```html <div class="grid-cols-[repeat(16,minmax(0,1fr))]"></div> ``` Because up until Tailwind CSS v4, we only generated 12 columns by default. But since v4, we can generate any number of columns automatically. Running the migration tool will give you this: ```html <div class="grid-cols-16"></div> ``` ### User CSS But, what if I told you that we can keep going... In [Catalyst](https://tailwindcss.com/plus/ui-kit) we often use classes that look like this for accessibility reasons: ```html <div class="text-[CanvasText] bg-[Highlight]"></div> ``` What if you want to move the `CanvasText` and `Highlight` colors to your CSS: ```css @import "tailwincdss"; @theme { --color-canvas: CanvasText; --color-highlight: Highlight; } ``` If you now run the upgrade tool again, this will be the result: ```html <div class="text-canvas bg-highlight"></div> ``` We never shipped a `text-canvas` or `bg-highlight` utility, but the upgrade tool uses your own CSS configuration to migrate your codebase. This will keep your codebase clean, consistent and modern and you are in control. Let's look at one more example, what if you have this in a lot of places: ```html <div class="[scrollbar-gutter:stable]"></div> ``` And you don't want to wait for the Tailwind CSS team to ship a `scrollbar-stable` (or similar) feature. You can add your own utility: ```css @import "tailwincdss"; @utility scrollbar-stable { scrollbar-gutter: stable; } ``` ```html <div class="scrollbar-stable"></div> ``` ## The solution — how it works There are 2 big things happening here: 1. Instead of us (the Tailwind CSS team) hardcoding certain migrations, we will make use of the internal `DesignSystem` which is the source of truth for all this information. This is also what Tailwind CSS itself uses to generate the CSS file. The internal `DesignSystem` is essentially a list of all: 1. The internal utilities 2. The internal variants 3. The default theme we ship 4. The user CSS 1. With custom `@theme` values 2. With custom `@custom-variant` implementations 3. With custom `@utility` implementations 2. The upgrade tool now has a concept of `signatures` The signatures part is the most interesting one, and it allows us to be 100% sure that we can migrate your codebase without breaking anything. A signature is some unique identifier that represents a utility. But 2 utilities that do the exact same thing will have the same signature. To make this work, we have to make sure that we normalize values. One such value is the selector. I think a little visualization will help here: | UTILITY | GENERATED SIGNATURE | | ---------------- | ----------------------- | | `[display:flex]` | `.x { display: flex; }` | | `flex` | `.x { display: flex; }` | They have the exact same signature and therefore the upgrade tool can safely migrate them to the same utility. For this we will prefer the following order: 1. Static utilities — essentially no brackets. E.g.: `flex`, `grid-cols-2` 2. Arbitrary values — e.g.: `max-h-[1lh]`, `border-[2px]` 3. Arbitrary properties — e.g.: `[color-scheme:dark]`, `[display:flex]` We also have to canonicalize utilities to there minimal form. Essentially making sure we increase the chance of finding a match. ``` [display:_flex_] → [display:flex] → flex [display:_flex] → [display:flex] → flex [display:flex_] → [display:flex] → flex [display:flex] → [display:flex] → flex ``` If we don't do this, then the signatures will be slightly different, due to the whitespace: | UTILITY | GENERATED SIGNATURE | | ------------------ | ------------------------- | | `[display:_flex_]` | `.x { display: flex ; }` | | `[display:_flex]` | `.x { display: flex; }` | | `[display:flex_]` | `.x { display: flex ; }` | | `[display:flex]` | `.x { display: flex; }` | ### Other small improvements A few other improvements are for optimizing existing utilities: 1. Remove unnecessary data types. E.g.: - `bg-[color:red]` -> `bg-[red]` - `shadow-[shadow:inset_0_1px_--theme(--color-white/15%)]` -> `shadow-[inset_0_1px_--theme(--color-white/15%)]` This also makes use of these signatures and if dropping the data type results in the same signature then we can safely drop it. Additionally, if a more specific utility exists, we will prefer that one. This reduced ambiguity and the need for data types. - `bg-[position:123px]` → `bg-position-[123px]` - `bg-[123px]` → `bg-position-[123px]` - `bg-[size:123px]` → `bg-size-[123px]` 2. Optimizing modifiers. E.g.: - `bg-red-500/[25%]` → `bg-red-500/25` - `bg-red-500/[100%]` → `bg-red-500` - `bg-red-500/100` → `bg-red-500` 3. Hoist `not` in arbitrary variants - `[@media_not_(prefers-color-scheme:dark)]:flex` → `not-[@media_(prefers-color-scheme:dark)]:flex` → `not-dark:flex` (in case you are using the default `dark` mode implementation 4. Optimize raw values that could be converted to bare values. This uses the `--spacing` variable to ensure it is safe. - `w-[64rem]` → `w-256` --------- Co-authored-by: Jordan Pittman <jordan@cryptica.me> Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
parent
45cd32eed7
commit
4e4275638f
10
CHANGELOG.md
10
CHANGELOG.md
@ -7,13 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Nothing yet!
|
||||
### Added
|
||||
|
||||
- Upgrade: Automatically convert candidates with arbitrary values to their utilities ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure negative arbitrary `scale` values generate negative values ([#17831](https://github.com/tailwindlabs/tailwindcss/pull/17831))
|
||||
|
||||
## [4.1.5] - 2025-04-30
|
||||
|
||||
### Added
|
||||
|
||||
- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
|
||||
- Support using `@tailwindcss/upgrade` to upgrade between versions of v4.\* ([#17717](https://github.com/tailwindlabs/tailwindcss/pull/17717))
|
||||
- Add `h-lh` / `min-h-lh` / `max-h-lh` utilities ([#17790](https://github.com/tailwindlabs/tailwindcss/pull/17790))
|
||||
- Transition `display`, `visibility`, `content-visibility`, `overlay`, and `pointer-events` when using `transition` to simplify `@starting-style` usage ([#17812](https://github.com/tailwindlabs/tailwindcss/pull/17812))
|
||||
|
||||
|
||||
@ -209,7 +209,7 @@ test(
|
||||
"
|
||||
--- ./src/index.html ---
|
||||
<div
|
||||
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:[color:red] tw:in-[.tw\\:group]:flex"
|
||||
class="tw:flex! tw:sm:block! tw:bg-linear-to-t flex tw:text-[red] tw:in-[.tw\\:group]:flex"
|
||||
></div>
|
||||
<div
|
||||
class="tw:group tw:group/foo tw:peer tw:peer/foo tw:group-hover:flex tw:group-hover/foo:flex tw:peer-hover:flex tw:peer-hover/foo:flex"
|
||||
|
||||
@ -1329,12 +1329,12 @@ describe('border compatibility', () => {
|
||||
"
|
||||
--- src/index.html ---
|
||||
<div
|
||||
class="[width:--spacing(2)]
|
||||
[width:--spacing(4.5)]
|
||||
[width:var(--spacing-5_5)]
|
||||
[width:--spacing(13)]
|
||||
[width:var(--spacing-100)]
|
||||
[width:var(--spacing-miami)]"
|
||||
class="w-2
|
||||
w-4.5
|
||||
w-5.5
|
||||
w-13
|
||||
w-100
|
||||
w-miami"
|
||||
></div>
|
||||
|
||||
--- src/input.css ---
|
||||
@ -1439,12 +1439,12 @@ describe('border compatibility', () => {
|
||||
"
|
||||
--- src/index.html ---
|
||||
<div
|
||||
class="[width:var(--spacing-2)]
|
||||
[width:var(--spacing-4_5)]
|
||||
[width:var(--spacing-5_5)]
|
||||
[width:var(--spacing-13)]
|
||||
[width:var(--spacing-100)]
|
||||
[width:var(--spacing-miami)]"
|
||||
class="w-2
|
||||
w-4.5
|
||||
w-5.5
|
||||
w-13
|
||||
w-100
|
||||
w-miami"
|
||||
></div>
|
||||
|
||||
--- src/input.css ---
|
||||
|
||||
@ -113,7 +113,7 @@ it('should apply all candidate migration when migrating with a config', async ()
|
||||
`),
|
||||
).toMatchInlineSnapshot(`
|
||||
".foo {
|
||||
@apply tw:flex! tw:[color:var(--my-color)] tw:bg-linear-to-t;
|
||||
@apply tw:flex! tw:text-(--my-color) tw:bg-linear-to-t;
|
||||
}"
|
||||
`)
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { spliceChangesIntoString } from '../../utils/splice-changes-into-string'
|
||||
import { extractRawCandidates, printCandidate } from './candidates'
|
||||
import { extractRawCandidates } from './candidates'
|
||||
|
||||
let html = String.raw
|
||||
|
||||
@ -190,7 +190,7 @@ describe('printCandidate()', () => {
|
||||
|
||||
// Sometimes we will have a functional and a static candidate for the same
|
||||
// raw input string (e.g. `-inset-full`). Dedupe in this case.
|
||||
let cleaned = new Set([...candidates].map((c) => printCandidate(designSystem, c)))
|
||||
let cleaned = new Set([...candidates].map((c) => designSystem.printCandidate(c)))
|
||||
|
||||
expect([...cleaned]).toEqual([result])
|
||||
})
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Scanner } from '@tailwindcss/oxide'
|
||||
import type { Candidate, Variant } from '../../../../tailwindcss/src/candidate'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
|
||||
|
||||
export async function extractRawCandidates(
|
||||
content: string,
|
||||
@ -16,273 +13,3 @@ export async function extractRawCandidates(
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function printCandidate(designSystem: DesignSystem, candidate: Candidate) {
|
||||
let parts: string[] = []
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
parts.unshift(printVariant(variant))
|
||||
}
|
||||
|
||||
// Handle prefix
|
||||
if (designSystem.theme.prefix) {
|
||||
parts.unshift(designSystem.theme.prefix)
|
||||
}
|
||||
|
||||
let base: string = ''
|
||||
|
||||
// Handle static
|
||||
if (candidate.kind === 'static') {
|
||||
base += candidate.root
|
||||
}
|
||||
|
||||
// Handle functional
|
||||
if (candidate.kind === 'functional') {
|
||||
base += candidate.root
|
||||
|
||||
if (candidate.value) {
|
||||
if (candidate.value.kind === 'arbitrary') {
|
||||
if (candidate.value !== null) {
|
||||
let isVarValue = isVar(candidate.value.value)
|
||||
let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value
|
||||
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
|
||||
|
||||
if (candidate.value.dataType) {
|
||||
base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}`
|
||||
} else {
|
||||
base += `-${open}${printArbitraryValue(value)}${close}`
|
||||
}
|
||||
}
|
||||
} else if (candidate.value.kind === 'named') {
|
||||
base += `-${candidate.value.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arbitrary
|
||||
if (candidate.kind === 'arbitrary') {
|
||||
base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]`
|
||||
}
|
||||
|
||||
// Handle modifier
|
||||
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
|
||||
if (candidate.modifier) {
|
||||
let isVarValue = isVar(candidate.modifier.value)
|
||||
let value = isVarValue ? candidate.modifier.value.slice(4, -1) : candidate.modifier.value
|
||||
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
|
||||
|
||||
if (candidate.modifier.kind === 'arbitrary') {
|
||||
base += `/${open}${printArbitraryValue(value)}${close}`
|
||||
} else if (candidate.modifier.kind === 'named') {
|
||||
base += `/${candidate.modifier.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle important
|
||||
if (candidate.important) {
|
||||
base += '!'
|
||||
}
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join(':')
|
||||
}
|
||||
|
||||
function printVariant(variant: Variant) {
|
||||
// Handle static variants
|
||||
if (variant.kind === 'static') {
|
||||
return variant.root
|
||||
}
|
||||
|
||||
// Handle arbitrary variants
|
||||
if (variant.kind === 'arbitrary') {
|
||||
return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]`
|
||||
}
|
||||
|
||||
let base: string = ''
|
||||
|
||||
// Handle functional variants
|
||||
if (variant.kind === 'functional') {
|
||||
base += variant.root
|
||||
// `@` is a special case for functional variants. We want to print: `@lg`
|
||||
// instead of `@-lg`
|
||||
let hasDash = variant.root !== '@'
|
||||
if (variant.value) {
|
||||
if (variant.value.kind === 'arbitrary') {
|
||||
let isVarValue = isVar(variant.value.value)
|
||||
let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value
|
||||
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
|
||||
|
||||
base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}`
|
||||
} else if (variant.value.kind === 'named') {
|
||||
base += `${hasDash ? '-' : ''}${variant.value.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compound variants
|
||||
if (variant.kind === 'compound') {
|
||||
base += variant.root
|
||||
base += '-'
|
||||
base += printVariant(variant.variant)
|
||||
}
|
||||
|
||||
// Handle modifiers
|
||||
if (variant.kind === 'functional' || variant.kind === 'compound') {
|
||||
if (variant.modifier) {
|
||||
if (variant.modifier.kind === 'arbitrary') {
|
||||
base += `/[${printArbitraryValue(variant.modifier.value)}]`
|
||||
} else if (variant.modifier.kind === 'named') {
|
||||
base += `/${variant.modifier.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
function printArbitraryValue(input: string) {
|
||||
let ast = ValueParser.parse(input)
|
||||
|
||||
let drop = new Set<ValueParser.ValueAstNode>()
|
||||
|
||||
ValueParser.walk(ast, (node, { parent }) => {
|
||||
let parentArray = parent === null ? ast : (parent.nodes ?? [])
|
||||
|
||||
// Handle operators (e.g.: inside of `calc(…)`)
|
||||
if (
|
||||
node.kind === 'word' &&
|
||||
// Operators
|
||||
(node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/')
|
||||
) {
|
||||
let idx = parentArray.indexOf(node) ?? -1
|
||||
|
||||
// This should not be possible
|
||||
if (idx === -1) return
|
||||
|
||||
let previous = parentArray[idx - 1]
|
||||
if (previous?.kind !== 'separator' || previous.value !== ' ') return
|
||||
|
||||
let next = parentArray[idx + 1]
|
||||
if (next?.kind !== 'separator' || next.value !== ' ') return
|
||||
|
||||
drop.add(previous)
|
||||
drop.add(next)
|
||||
}
|
||||
|
||||
// The value parser handles `/` as a separator in some scenarios. E.g.:
|
||||
// `theme(colors.red/50%)`. Because of this, we have to handle this case
|
||||
// separately.
|
||||
else if (node.kind === 'separator' && node.value.trim() === '/') {
|
||||
node.value = '/'
|
||||
}
|
||||
|
||||
// Leading and trailing whitespace
|
||||
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
|
||||
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {
|
||||
drop.add(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Whitespace around `,` separators can be removed.
|
||||
// E.g.: `min(1px , 2px)` -> `min(1px,2px)`
|
||||
else if (node.kind === 'separator' && node.value.trim() === ',') {
|
||||
node.value = ','
|
||||
}
|
||||
})
|
||||
|
||||
if (drop.size > 0) {
|
||||
ValueParser.walk(ast, (node, { replaceWith }) => {
|
||||
if (drop.has(node)) {
|
||||
drop.delete(node)
|
||||
replaceWith([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
recursivelyEscapeUnderscores(ast)
|
||||
|
||||
return ValueParser.toCss(ast)
|
||||
}
|
||||
|
||||
function simplifyArbitraryVariant(input: string) {
|
||||
let ast = ValueParser.parse(input)
|
||||
|
||||
// &:is(…)
|
||||
if (
|
||||
ast.length === 3 &&
|
||||
// &
|
||||
ast[0].kind === 'word' &&
|
||||
ast[0].value === '&' &&
|
||||
// :
|
||||
ast[1].kind === 'separator' &&
|
||||
ast[1].value === ':' &&
|
||||
// is(…)
|
||||
ast[2].kind === 'function' &&
|
||||
ast[2].value === 'is'
|
||||
) {
|
||||
return ValueParser.toCss(ast[2].nodes)
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
|
||||
for (let node of ast) {
|
||||
switch (node.kind) {
|
||||
case 'function': {
|
||||
if (node.value === 'url' || node.value.endsWith('_url')) {
|
||||
// Don't decode underscores in url() but do decode the function name
|
||||
node.value = escapeUnderscore(node.value)
|
||||
break
|
||||
}
|
||||
|
||||
if (
|
||||
node.value === 'var' ||
|
||||
node.value.endsWith('_var') ||
|
||||
node.value === 'theme' ||
|
||||
node.value.endsWith('_theme')
|
||||
) {
|
||||
node.value = escapeUnderscore(node.value)
|
||||
for (let i = 0; i < node.nodes.length; i++) {
|
||||
recursivelyEscapeUnderscores([node.nodes[i]])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
node.value = escapeUnderscore(node.value)
|
||||
recursivelyEscapeUnderscores(node.nodes)
|
||||
break
|
||||
}
|
||||
case 'separator':
|
||||
node.value = escapeUnderscore(node.value)
|
||||
break
|
||||
case 'word': {
|
||||
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
|
||||
// have underscores escaped
|
||||
if (node.value[0] !== '-' && node.value[1] !== '-') {
|
||||
node.value = escapeUnderscore(node.value)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
never(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isVar(value: string) {
|
||||
let ast = ValueParser.parse(value)
|
||||
return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var'
|
||||
}
|
||||
|
||||
function never(value: never): never {
|
||||
throw new Error(`Unexpected value: ${value}`)
|
||||
}
|
||||
|
||||
function escapeUnderscore(value: string): string {
|
||||
return value
|
||||
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
|
||||
.replaceAll(' ', '_') // Replace spaces with underscores
|
||||
}
|
||||
|
||||
@ -0,0 +1,349 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
||||
import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities'
|
||||
import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value'
|
||||
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
|
||||
import { migrateOptimizeModifier } from './migrate-optimize-modifier'
|
||||
|
||||
const designSystems = new DefaultMap((base: string) => {
|
||||
return new DefaultMap((input: string) => {
|
||||
return __unstable__loadDesignSystem(input, { base })
|
||||
})
|
||||
})
|
||||
|
||||
function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) {
|
||||
for (let migration of [
|
||||
migrateArbitraryUtilities,
|
||||
migrateDropUnnecessaryDataTypes,
|
||||
migrateArbitraryValueToBareValue,
|
||||
migrateOptimizeModifier,
|
||||
]) {
|
||||
rawCandidate = migration(designSystem, userConfig, rawCandidate)
|
||||
}
|
||||
return rawCandidate
|
||||
}
|
||||
|
||||
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
|
||||
let testName = '%s => %s (%#)'
|
||||
if (strategy === 'with-variant') {
|
||||
testName = testName.replaceAll('%s', 'focus:%s')
|
||||
} else if (strategy === 'important') {
|
||||
testName = testName.replaceAll('%s', '%s!')
|
||||
} else if (strategy === 'prefix') {
|
||||
testName = testName.replaceAll('%s', 'tw:%s')
|
||||
}
|
||||
|
||||
// Basic input with minimal design system to keep the tests fast
|
||||
let input = css`
|
||||
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
|
||||
@theme {
|
||||
--*: initial;
|
||||
--spacing: 0.25rem;
|
||||
--color-red-500: red;
|
||||
|
||||
/* Equivalent of blue-500/50 */
|
||||
--color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent);
|
||||
}
|
||||
`
|
||||
|
||||
test.each([
|
||||
// Arbitrary property to static utility
|
||||
['[text-wrap:balance]', 'text-balance'],
|
||||
|
||||
// Arbitrary property to static utility with slight differences in
|
||||
// whitespace. This will require some canonicalization.
|
||||
['[display:_flex_]', 'flex'],
|
||||
['[display:_flex]', 'flex'],
|
||||
['[display:flex_]', 'flex'],
|
||||
|
||||
// Arbitrary property to static utility
|
||||
// Map number to keyword-like value
|
||||
['leading-[1]', 'leading-none'],
|
||||
|
||||
// Arbitrary property to named functional utility
|
||||
['[color:var(--color-red-500)]', 'text-red-500'],
|
||||
['[background-color:var(--color-red-500)]', 'bg-red-500'],
|
||||
|
||||
// Arbitrary property with modifier to named functional utility with modifier
|
||||
['[color:var(--color-red-500)]/25', 'text-red-500/25'],
|
||||
|
||||
// Arbitrary property with arbitrary modifier to named functional utility with
|
||||
// arbitrary modifier
|
||||
['[color:var(--color-red-500)]/[25%]', 'text-red-500/25'],
|
||||
['[color:var(--color-red-500)]/[100%]', 'text-red-500'],
|
||||
['[color:var(--color-red-500)]/100', 'text-red-500'],
|
||||
// No need for `/50` because that's already encoded in the `--color-primary`
|
||||
// value
|
||||
['[color:oklch(62.3%_0.214_259.815)]/50', 'text-primary'],
|
||||
|
||||
// Arbitrary property to arbitrary value
|
||||
['[max-height:20px]', 'max-h-[20px]'],
|
||||
|
||||
// Arbitrary property to bare value
|
||||
['[grid-column:2]', 'col-2'],
|
||||
['[grid-column:1234]', 'col-1234'],
|
||||
|
||||
// Arbitrary value to bare value
|
||||
['border-[2px]', 'border-2'],
|
||||
['border-[1234px]', 'border-1234'],
|
||||
|
||||
// Arbitrary value with data type, to more specific arbitrary value
|
||||
['bg-[position:123px]', 'bg-position-[123px]'],
|
||||
['bg-[size:123px]', 'bg-size-[123px]'],
|
||||
|
||||
// Arbitrary value with inferred data type, to more specific arbitrary value
|
||||
['bg-[123px]', 'bg-position-[123px]'],
|
||||
|
||||
// Arbitrary value with spacing mul
|
||||
['w-[64rem]', 'w-256'],
|
||||
|
||||
// Complex arbitrary property to arbitrary value
|
||||
[
|
||||
'[grid-template-columns:repeat(2,minmax(100px,1fr))]',
|
||||
'grid-cols-[repeat(2,minmax(100px,1fr))]',
|
||||
],
|
||||
// Complex arbitrary property to bare value
|
||||
['[grid-template-columns:repeat(2,minmax(0,1fr))]', 'grid-cols-2'],
|
||||
|
||||
// Arbitrary value to bare value with percentage
|
||||
['from-[25%]', 'from-25%'],
|
||||
|
||||
// Arbitrary percentage value must be a whole number. Should not migrate to
|
||||
// a bare value.
|
||||
['from-[2.5%]', 'from-[2.5%]'],
|
||||
])(testName, async (candidate, result) => {
|
||||
if (strategy === 'with-variant') {
|
||||
candidate = `focus:${candidate}`
|
||||
result = `focus:${result}`
|
||||
} else if (strategy === 'important') {
|
||||
candidate = `${candidate}!`
|
||||
result = `${result}!`
|
||||
} else if (strategy === 'prefix') {
|
||||
// Not only do we need to prefix the candidate, we also have to make
|
||||
// sure that we prefix all CSS variables.
|
||||
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
|
||||
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
|
||||
}
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
})
|
||||
|
||||
const css = String.raw
|
||||
test('migrate with custom static utility `@utility custom {…}`', async () => {
|
||||
let candidate = '[--key:value]'
|
||||
let result = 'custom'
|
||||
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
@utility custom {
|
||||
--key: value;
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
test('migrate with custom functional utility `@utility custom-* {…}`', async () => {
|
||||
let candidate = '[--key:value]'
|
||||
let result = 'custom-value'
|
||||
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
@utility custom-* {
|
||||
--key: --value('value');
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
test('migrate with custom functional utility `@utility custom-* {…}` that supports bare values', async () => {
|
||||
let candidate = '[tab-size:4]'
|
||||
let result = 'tab-4'
|
||||
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
@utility tab-* {
|
||||
tab-size: --value(integer);
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['[tab-size:0]', 'tab-0'],
|
||||
['[tab-size:4]', 'tab-4'],
|
||||
['[tab-size:8]', 'tab-github'],
|
||||
['tab-[0]', 'tab-0'],
|
||||
['tab-[4]', 'tab-4'],
|
||||
['tab-[8]', 'tab-github'],
|
||||
])(
|
||||
'migrate custom @utility from arbitrary values to bare values and named values (based on theme)',
|
||||
async (candidate, expected) => {
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
--tab-size-github: 8;
|
||||
}
|
||||
|
||||
@utility tab-* {
|
||||
tab-size: --value(--tab-size, integer, [integer]);
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
describe.each([['@theme'], ['@theme inline']])('%s', (theme) => {
|
||||
test.each([
|
||||
['[color:CanvasText]', 'text-canvas'],
|
||||
['text-[CanvasText]', 'text-canvas'],
|
||||
])('migrate arbitrary value to theme value %s => %s', async (candidate, result) => {
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
${theme} {
|
||||
--*: initial;
|
||||
--color-canvas: CanvasText;
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
// Some utilities read from specific namespaces, in this case we do not want
|
||||
// to migrate to a value in that namespace if we reference a variable that
|
||||
// results in the same value, but comes from a different namespace.
|
||||
//
|
||||
// E.g.: `max-w` reads from: ['--max-width', '--spacing', '--container']
|
||||
test.each([
|
||||
// `max-w` does not read from `--breakpoint-md`, but `--breakpoint-md` and
|
||||
// `--container-3xl` happen to result in the same value. The difference is
|
||||
// the semantics of the value.
|
||||
['max-w-(--breakpoint-md)', 'max-w-(--breakpoint-md)'],
|
||||
['max-w-(--container-3xl)', 'max-w-3xl'],
|
||||
])('migrate arbitrary value to theme value %s => %s', async (candidate, result) => {
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
${theme} {
|
||||
--*: initial;
|
||||
--breakpoint-md: 48rem;
|
||||
--container-3xl: 48rem;
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
})
|
||||
|
||||
test('migrate a arbitrary property without spaces, to a theme value with spaces (canonicalization)', async () => {
|
||||
let candidate = 'font-[foo,bar,baz]'
|
||||
let expected = 'font-example'
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
--font-example: foo, bar, baz;
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(expected)
|
||||
})
|
||||
|
||||
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
|
||||
let testName = '%s => %s (%#)'
|
||||
if (strategy === 'with-variant') {
|
||||
testName = testName.replaceAll('%s', 'focus:%s')
|
||||
} else if (strategy === 'important') {
|
||||
testName = testName.replaceAll('%s', '%s!')
|
||||
} else if (strategy === 'prefix') {
|
||||
testName = testName.replaceAll('%s', 'tw:%s')
|
||||
}
|
||||
test.each([
|
||||
// Default spacing scale
|
||||
['w-[64rem]', 'w-256', '0.25rem'],
|
||||
|
||||
// Keep arbitrary value if units are different
|
||||
['w-[124px]', 'w-[124px]', '0.25rem'],
|
||||
|
||||
// Keep arbitrary value if bare value doesn't fit in steps of .25
|
||||
['w-[0.123rem]', 'w-[0.123rem]', '0.25rem'],
|
||||
|
||||
// Custom pixel based spacing scale
|
||||
['w-[123px]', 'w-123', '1px'],
|
||||
['w-[256px]', 'w-128', '2px'],
|
||||
])(testName, async (candidate, expected, spacing) => {
|
||||
if (strategy === 'with-variant') {
|
||||
candidate = `focus:${candidate}`
|
||||
expected = `focus:${expected}`
|
||||
} else if (strategy === 'important') {
|
||||
candidate = `${candidate}!`
|
||||
expected = `${expected}!`
|
||||
} else if (strategy === 'prefix') {
|
||||
// Not only do we need to prefix the candidate, we also have to make
|
||||
// sure that we prefix all CSS variables.
|
||||
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
|
||||
expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}`
|
||||
}
|
||||
|
||||
let input = css`
|
||||
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
|
||||
|
||||
@theme {
|
||||
--*: initial;
|
||||
--spacing: ${spacing};
|
||||
}
|
||||
`
|
||||
let designSystem = await __unstable__loadDesignSystem(input, {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
let migrated = migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,339 @@
|
||||
import { printModifier, type Candidate } 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 { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
|
||||
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
|
||||
import { dimensions } from '../../utils/dimension'
|
||||
import type { Writable } from '../../utils/types'
|
||||
import { computeUtilitySignature } from './signatures'
|
||||
|
||||
// For all static utilities in the system, compute a lookup table that maps the
|
||||
// utility signature to the utility name. This is used to find the utility name
|
||||
// for a given utility signature.
|
||||
//
|
||||
// For all functional utilities, we can compute static-like utilities by
|
||||
// essentially pre-computing the values and modifiers. This is a bit slow, but
|
||||
// also only has to happen once per design system.
|
||||
const preComputedUtilities = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>((ds) => {
|
||||
let signatures = computeUtilitySignature.get(ds)
|
||||
let lookup = new DefaultMap<string, string[]>(() => [])
|
||||
|
||||
for (let [className, meta] of ds.getClassList()) {
|
||||
let signature = signatures.get(className)
|
||||
if (typeof signature !== 'string') continue
|
||||
lookup.get(signature).push(className)
|
||||
|
||||
for (let modifier of meta.modifiers) {
|
||||
// Modifiers representing numbers can be computed and don't need to be
|
||||
// pre-computed. Doing the math and at the time of writing this, this
|
||||
// would save you 250k additionally pre-computed utilities...
|
||||
if (isValidSpacingMultiplier(modifier)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let classNameWithModifier = `${className}/${modifier}`
|
||||
let signature = signatures.get(classNameWithModifier)
|
||||
if (typeof signature !== 'string') continue
|
||||
lookup.get(signature).push(classNameWithModifier)
|
||||
}
|
||||
}
|
||||
|
||||
return lookup
|
||||
})
|
||||
|
||||
const baseReplacementsCache = new DefaultMap<DesignSystem, Map<string, Candidate>>(
|
||||
() => new Map<string, Candidate>(),
|
||||
)
|
||||
|
||||
const spacing = new DefaultMap<DesignSystem, DefaultMap<string, number | null> | null>((ds) => {
|
||||
let spacingMultiplier = ds.resolveThemeValue('--spacing')
|
||||
if (spacingMultiplier === undefined) return null
|
||||
|
||||
let parsed = dimensions.get(spacingMultiplier)
|
||||
if (!parsed) return null
|
||||
|
||||
let [value, unit] = parsed
|
||||
|
||||
return new DefaultMap<string, number | null>((input) => {
|
||||
let parsed = dimensions.get(input)
|
||||
if (!parsed) return null
|
||||
|
||||
let [myValue, myUnit] = parsed
|
||||
if (myUnit !== unit) return null
|
||||
|
||||
return myValue / value
|
||||
})
|
||||
})
|
||||
|
||||
export function migrateArbitraryUtilities(
|
||||
designSystem: DesignSystem,
|
||||
_userConfig: Config | null,
|
||||
rawCandidate: string,
|
||||
): string {
|
||||
let utilities = preComputedUtilities.get(designSystem)
|
||||
let signatures = computeUtilitySignature.get(designSystem)
|
||||
|
||||
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
|
||||
// We are only interested in arbitrary properties and arbitrary values
|
||||
if (
|
||||
// Arbitrary property
|
||||
readonlyCandidate.kind !== 'arbitrary' &&
|
||||
// Arbitrary value
|
||||
!(readonlyCandidate.kind === 'functional' && readonlyCandidate.value?.kind === 'arbitrary')
|
||||
) {
|
||||
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>
|
||||
|
||||
// Create a basic stripped candidate without variants or important flag. We
|
||||
// will re-add those later but they are irrelevant for what we are trying to
|
||||
// do here (and will increase cache hits because we only have to deal with
|
||||
// the base utility, nothing more).
|
||||
let targetCandidate = structuredClone(candidate)
|
||||
targetCandidate.important = false
|
||||
targetCandidate.variants = []
|
||||
|
||||
let targetCandidateString = designSystem.printCandidate(targetCandidate)
|
||||
if (baseReplacementsCache.get(designSystem).has(targetCandidateString)) {
|
||||
let target = structuredClone(
|
||||
baseReplacementsCache.get(designSystem).get(targetCandidateString)!,
|
||||
)
|
||||
// Re-add the variants and important flag from the original candidate
|
||||
target.variants = candidate.variants
|
||||
target.important = candidate.important
|
||||
|
||||
return designSystem.printCandidate(target)
|
||||
}
|
||||
|
||||
// Compute the signature for the target candidate
|
||||
let targetSignature = signatures.get(targetCandidateString)
|
||||
if (typeof targetSignature !== 'string') continue
|
||||
|
||||
// Try a few options to find a suitable replacement utility
|
||||
for (let replacementCandidate of tryReplacements(targetSignature, targetCandidate)) {
|
||||
let replacementString = designSystem.printCandidate(replacementCandidate)
|
||||
let replacementSignature = signatures.get(replacementString)
|
||||
if (replacementSignature !== targetSignature) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure that if CSS variables were used, that they are still used
|
||||
if (!allVariablesAreUsed(designSystem, candidate, replacementCandidate)) {
|
||||
continue
|
||||
}
|
||||
|
||||
replacementCandidate = structuredClone(replacementCandidate)
|
||||
|
||||
// Cache the result so we can re-use this work later
|
||||
baseReplacementsCache.get(designSystem).set(targetCandidateString, replacementCandidate)
|
||||
|
||||
// Re-add the variants and important flag from the original candidate
|
||||
replacementCandidate.variants = candidate.variants
|
||||
replacementCandidate.important = candidate.important
|
||||
|
||||
// Update the candidate with the new value
|
||||
Object.assign(candidate, replacementCandidate)
|
||||
|
||||
// We will re-print the candidate to get the migrated candidate out
|
||||
return designSystem.printCandidate(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
|
||||
function* tryReplacements(
|
||||
targetSignature: string,
|
||||
candidate: Extract<Candidate, { kind: 'functional' | 'arbitrary' }>,
|
||||
): Generator<Candidate> {
|
||||
// Find a corresponding utility for the same signature
|
||||
let replacements = utilities.get(targetSignature)
|
||||
|
||||
// Multiple utilities can map to the same signature. Not sure how to migrate
|
||||
// this one so let's just skip it for now.
|
||||
//
|
||||
// TODO: Do we just migrate to the first one?
|
||||
if (replacements.length > 1) return
|
||||
|
||||
// If we didn't find any replacement utilities, let's try to strip the
|
||||
// modifier and find a replacement then. If we do, we can try to re-add the
|
||||
// modifier later and verify if we have a valid migration.
|
||||
//
|
||||
// This is necessary because `text-red-500/50` will not be pre-computed,
|
||||
// only `text-red-500` will.
|
||||
if (replacements.length === 0 && candidate.modifier) {
|
||||
let candidateWithoutModifier = { ...candidate, modifier: null }
|
||||
let targetSignatureWithoutModifier = signatures.get(
|
||||
designSystem.printCandidate(candidateWithoutModifier),
|
||||
)
|
||||
if (typeof targetSignatureWithoutModifier === 'string') {
|
||||
for (let replacementCandidate of tryReplacements(
|
||||
targetSignatureWithoutModifier,
|
||||
candidateWithoutModifier,
|
||||
)) {
|
||||
yield Object.assign({}, replacementCandidate, { modifier: candidate.modifier })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If only a single utility maps to the signature, we can use that as the
|
||||
// replacement.
|
||||
if (replacements.length === 1) {
|
||||
for (let replacementCandidate of parseCandidate(designSystem, replacements[0])) {
|
||||
yield replacementCandidate
|
||||
}
|
||||
}
|
||||
|
||||
// Find a corresponding functional utility for the same signature
|
||||
else if (replacements.length === 0) {
|
||||
// An arbitrary property will only set a single property, we can use that
|
||||
// to find functional utilities that also set this property.
|
||||
let value =
|
||||
candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null)
|
||||
if (value === null) return
|
||||
|
||||
let spacingMultiplier = spacing.get(designSystem)?.get(value)
|
||||
|
||||
for (let root of designSystem.utilities.keys('functional')) {
|
||||
// Try as bare value
|
||||
for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) {
|
||||
yield replacementCandidate
|
||||
}
|
||||
|
||||
// Try as bare value with modifier
|
||||
if (candidate.modifier) {
|
||||
for (let replacementCandidate of parseCandidate(
|
||||
designSystem,
|
||||
`${root}-${value}${candidate.modifier}`,
|
||||
)) {
|
||||
yield replacementCandidate
|
||||
}
|
||||
}
|
||||
|
||||
// Try bare value based on the `--spacing` value. E.g.:
|
||||
//
|
||||
// - `w-[64rem]` → `w-256`
|
||||
if (spacingMultiplier !== null) {
|
||||
for (let replacementCandidate of parseCandidate(
|
||||
designSystem,
|
||||
`${root}-${spacingMultiplier}`,
|
||||
)) {
|
||||
yield replacementCandidate
|
||||
}
|
||||
|
||||
// Try bare value based on the `--spacing` value, but with a modifier
|
||||
if (candidate.modifier) {
|
||||
for (let replacementCandidate of parseCandidate(
|
||||
designSystem,
|
||||
`${root}-${spacingMultiplier}${printModifier(candidate.modifier)}`,
|
||||
)) {
|
||||
yield replacementCandidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try as arbitrary value
|
||||
for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) {
|
||||
yield replacementCandidate
|
||||
}
|
||||
|
||||
// Try as arbitrary value with modifier
|
||||
if (candidate.modifier) {
|
||||
for (let replacementCandidate of parseCandidate(
|
||||
designSystem,
|
||||
`${root}-[${value}]${printModifier(candidate.modifier)}`,
|
||||
)) {
|
||||
yield replacementCandidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCandidate(designSystem: DesignSystem, input: string) {
|
||||
return designSystem.parseCandidate(
|
||||
designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`)
|
||||
? `${designSystem.theme.prefix}:${input}`
|
||||
: input,
|
||||
)
|
||||
}
|
||||
|
||||
// Let's make sure that all variables used in the value are also all used in the
|
||||
// found replacement. If not, then we are dealing with a different namespace or
|
||||
// we could lose functionality in case the variable was changed higher up in the
|
||||
// DOM tree.
|
||||
function allVariablesAreUsed(
|
||||
designSystem: DesignSystem,
|
||||
candidate: Candidate,
|
||||
replacement: Candidate,
|
||||
) {
|
||||
let value: string | null = null
|
||||
|
||||
// Functional utility with arbitrary value and variables
|
||||
if (
|
||||
candidate.kind === 'functional' &&
|
||||
candidate.value?.kind === 'arbitrary' &&
|
||||
candidate.value.value.includes('var(--')
|
||||
) {
|
||||
value = candidate.value.value
|
||||
}
|
||||
|
||||
// Arbitrary property with variables
|
||||
else if (candidate.kind === 'arbitrary' && candidate.value.includes('var(--')) {
|
||||
value = candidate.value
|
||||
}
|
||||
|
||||
// No variables in the value, so this is a safe migration
|
||||
if (value === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
let replacementAsCss = designSystem
|
||||
.candidatesToCss([designSystem.printCandidate(replacement)])
|
||||
.join('\n')
|
||||
|
||||
let isSafeMigration = true
|
||||
ValueParser.walk(ValueParser.parse(value), (node) => {
|
||||
if (node.kind === 'function' && node.value === 'var') {
|
||||
let variable = node.nodes[0].value
|
||||
let r = new RegExp(`var\\(${variable}[,)]\\s*`, 'g')
|
||||
if (
|
||||
// We need to check if the variable is used in the replacement
|
||||
!r.test(replacementAsCss) ||
|
||||
// The value cannot be set to a different value in the
|
||||
// replacement because that would make it an unsafe migration
|
||||
replacementAsCss.includes(`${variable}:`)
|
||||
) {
|
||||
isSafeMigration = false
|
||||
return ValueParser.ValueWalkAction.Stop
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return isSafeMigration
|
||||
}
|
||||
@ -34,7 +34,6 @@ test.each([
|
||||
|
||||
// Leading is special, because `leading-[123]` is the direct value of 123, but
|
||||
// `leading-123` maps to `calc(--spacing(123))`.
|
||||
['leading-[1]', 'leading-none'],
|
||||
['leading-[123]', 'leading-[123]'],
|
||||
|
||||
['data-[selected]:flex', 'data-selected:flex'],
|
||||
@ -60,7 +59,7 @@ test.each([
|
||||
'data-[selected]:aria-[selected="true"]:aspect-[12/34]',
|
||||
'data-selected:aria-selected:aspect-12/34',
|
||||
],
|
||||
])('%s => %s', async (candidate, result) => {
|
||||
])('%s => %s (%#)', async (candidate, result) => {
|
||||
let designSystem = await __unstable__loadDesignSystem('@import "tailwindcss";', {
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
@ -1,127 +1,45 @@
|
||||
import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
|
||||
import {
|
||||
parseCandidate,
|
||||
type Candidate,
|
||||
type NamedUtilityValue,
|
||||
} from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type'
|
||||
import {
|
||||
isPositiveInteger,
|
||||
isValidSpacingMultiplier,
|
||||
} from '../../../../tailwindcss/src/utils/infer-data-type'
|
||||
import { segment } from '../../../../tailwindcss/src/utils/segment'
|
||||
import { printCandidate } from './candidates'
|
||||
import { walkVariants } from '../../utils/walk-variants'
|
||||
import { computeUtilitySignature } from './signatures'
|
||||
|
||||
export function migrateArbitraryValueToBareValue(
|
||||
designSystem: DesignSystem,
|
||||
_userConfig: Config | null,
|
||||
rawCandidate: string,
|
||||
): string {
|
||||
let signatures = computeUtilitySignature.get(designSystem)
|
||||
|
||||
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
|
||||
let clone = structuredClone(candidate)
|
||||
let changed = false
|
||||
|
||||
// Convert [subgrid] to subgrid
|
||||
if (
|
||||
clone.kind === 'functional' &&
|
||||
clone.value?.kind === 'arbitrary' &&
|
||||
clone.value.value === 'subgrid' &&
|
||||
(clone.root === 'grid-cols' || clone.root == 'grid-rows')
|
||||
) {
|
||||
changed = true
|
||||
clone.value = {
|
||||
kind: 'named',
|
||||
value: 'subgrid',
|
||||
fraction: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert utilities that accept bare values ending in %
|
||||
if (
|
||||
clone.kind === 'functional' &&
|
||||
clone.value?.kind === 'arbitrary' &&
|
||||
clone.value.dataType === null &&
|
||||
(clone.root === 'from' ||
|
||||
clone.root === 'via' ||
|
||||
clone.root === 'to' ||
|
||||
clone.root === 'font-stretch')
|
||||
) {
|
||||
if (clone.value.value.endsWith('%') && isPositiveInteger(clone.value.value.slice(0, -1))) {
|
||||
let percentage = parseInt(clone.value.value)
|
||||
if (
|
||||
clone.root === 'from' ||
|
||||
clone.root === 'via' ||
|
||||
clone.root === 'to' ||
|
||||
(clone.root === 'font-stretch' && percentage >= 50 && percentage <= 200)
|
||||
) {
|
||||
changed = true
|
||||
clone.value = {
|
||||
kind: 'named',
|
||||
value: clone.value.value,
|
||||
fraction: null,
|
||||
// Migrate arbitrary values to bare values
|
||||
if (clone.kind === 'functional' && clone.value?.kind === 'arbitrary') {
|
||||
let expectedSignature = signatures.get(rawCandidate)
|
||||
if (expectedSignature !== null) {
|
||||
for (let value of tryValueReplacements(clone)) {
|
||||
let newSignature = signatures.get(designSystem.printCandidate({ ...clone, value }))
|
||||
if (newSignature === expectedSignature) {
|
||||
changed = true
|
||||
clone.value = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert arbitrary values with positive integers to bare values
|
||||
// Convert arbitrary values with fractions to bare values
|
||||
else if (
|
||||
clone.kind === 'functional' &&
|
||||
clone.value?.kind === 'arbitrary' &&
|
||||
clone.value.dataType === null
|
||||
) {
|
||||
if (clone.root === 'leading') {
|
||||
// leading-[1] -> leading-none
|
||||
if (clone.value.value === '1') {
|
||||
changed = true
|
||||
clone.value = {
|
||||
kind: 'named',
|
||||
value: 'none',
|
||||
fraction: null,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep leading-[<number>] as leading-[<number>]
|
||||
else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let parts = segment(clone.value.value, '/')
|
||||
if (parts.every((part) => isPositiveInteger(part))) {
|
||||
changed = true
|
||||
|
||||
let currentValue = clone.value
|
||||
let currentModifier = clone.modifier
|
||||
|
||||
// E.g.: `col-start-[12]`
|
||||
// ^^
|
||||
if (parts.length === 1) {
|
||||
clone.value = {
|
||||
kind: 'named',
|
||||
value: clone.value.value,
|
||||
fraction: null,
|
||||
}
|
||||
}
|
||||
|
||||
// E.g.: `aspect-[12/34]`
|
||||
// ^^ ^^
|
||||
else {
|
||||
clone.value = {
|
||||
kind: 'named',
|
||||
value: parts[0],
|
||||
fraction: clone.value.value,
|
||||
}
|
||||
clone.modifier = {
|
||||
kind: 'named',
|
||||
value: parts[1],
|
||||
}
|
||||
}
|
||||
|
||||
// Double check that the new value compiles correctly
|
||||
if (designSystem.compileAstNodes(clone).length === 0) {
|
||||
clone.value = currentValue
|
||||
clone.modifier = currentModifier
|
||||
changed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let variant of variants(clone)) {
|
||||
for (let [variant] of walkVariants(clone)) {
|
||||
// Convert `data-[selected]` to `data-selected`
|
||||
if (
|
||||
variant.kind === 'functional' &&
|
||||
@ -183,21 +101,80 @@ export function migrateArbitraryValueToBareValue(
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? printCandidate(designSystem, clone) : rawCandidate
|
||||
return changed ? designSystem.printCandidate(clone) : rawCandidate
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
}
|
||||
|
||||
function* variants(candidate: Candidate) {
|
||||
function* inner(variant: Variant): Iterable<Variant> {
|
||||
yield variant
|
||||
if (variant.kind === 'compound') {
|
||||
yield* inner(variant.variant)
|
||||
// Convert functional utilities with arbitrary values to bare values if we can.
|
||||
// We know that bare values can only be:
|
||||
//
|
||||
// 1. A number (with increments of .25)
|
||||
// 2. A percentage (with increments of .25 followed by a `%`)
|
||||
// 3. A ratio with whole numbers
|
||||
//
|
||||
// Not a bare value per se, but if we are dealing with a keyword, that could
|
||||
// potentially also look like a bare value (aka no `[` or `]`). E.g.:
|
||||
// ```diff
|
||||
// grid-cols-[subgrid]
|
||||
// grid-cols-subgrid
|
||||
// ```
|
||||
function* tryValueReplacements(
|
||||
candidate: Extract<Candidate, { kind: 'functional' }>,
|
||||
value: string = candidate.value?.value ?? '',
|
||||
seen: Set<string> = new Set(),
|
||||
): Generator<NamedUtilityValue> {
|
||||
if (seen.has(value)) return
|
||||
seen.add(value)
|
||||
|
||||
// 0. Just try to drop the square brackets and see if it works
|
||||
// 1. A number (with increments of .25)
|
||||
yield {
|
||||
kind: 'named',
|
||||
value,
|
||||
fraction: null,
|
||||
}
|
||||
|
||||
// 2. A percentage (with increments of .25 followed by a `%`)
|
||||
// Try to drop the `%` and see if it works
|
||||
if (value.endsWith('%') && isValidSpacingMultiplier(value.slice(0, -1))) {
|
||||
yield {
|
||||
kind: 'named',
|
||||
value: value.slice(0, -1),
|
||||
fraction: null,
|
||||
}
|
||||
}
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
yield* inner(variant)
|
||||
// 3. A ratio with whole numbers
|
||||
if (value.includes('/')) {
|
||||
let [numerator, denominator] = value.split('/')
|
||||
if (isPositiveInteger(numerator) && isPositiveInteger(denominator)) {
|
||||
yield {
|
||||
kind: 'named',
|
||||
value: numerator,
|
||||
fraction: `${numerator}/${denominator}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// It could also be that we have `20px`, we can try just `20` and see if it
|
||||
// results in the same signature.
|
||||
let allNumbersAndFractions = new Set<string>()
|
||||
|
||||
// Figure out all numbers and fractions in the value
|
||||
for (let match of value.matchAll(/(\d+\/\d+)|(\d+\.?\d+)/g)) {
|
||||
allNumbersAndFractions.add(match[0].trim())
|
||||
}
|
||||
|
||||
// Sort the numbers and fractions where the smallest length comes first. This
|
||||
// will result in the smallest replacement.
|
||||
let options = Array.from(allNumbersAndFractions).sort((a, z) => {
|
||||
return a.length - z.length
|
||||
})
|
||||
|
||||
// Try all the options
|
||||
for (let option of options) {
|
||||
yield* tryValueReplacements(candidate, option, seen)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
||||
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
|
||||
|
||||
const css = String.raw
|
||||
const designSystems = new DefaultMap((base: string) => {
|
||||
return new DefaultMap((input: string) => {
|
||||
return __unstable__loadDesignSystem(input, { base })
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([['default'], ['important'], ['prefix']].slice(0, 1))('%s', (strategy) => {
|
||||
let testName = '%s => %s (%#)'
|
||||
if (strategy === 'with-variant') {
|
||||
testName = testName.replaceAll('%s', 'focus:%s')
|
||||
} else if (strategy === 'important') {
|
||||
testName = testName.replaceAll('%s', '%s!')
|
||||
} else if (strategy === 'prefix') {
|
||||
testName = testName.replaceAll('%s', 'tw:%s')
|
||||
}
|
||||
|
||||
// Basic input with minimal design system to keep the tests fast
|
||||
let input = css`
|
||||
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
`
|
||||
|
||||
test.each([
|
||||
// Arbitrary variant to static variant
|
||||
['[&:focus]:flex', 'focus:flex'],
|
||||
|
||||
// Arbitrary variant to static variant with at-rules
|
||||
['[@media(scripting:_none)]:flex', 'noscript:flex'],
|
||||
|
||||
// Arbitrary variant to static utility at-rules and with slight differences
|
||||
// in whitespace. This will require some canonicalization.
|
||||
['[@media(scripting:none)]:flex', 'noscript:flex'],
|
||||
['[@media(scripting:_none)]:flex', 'noscript:flex'],
|
||||
['[@media_(scripting:_none)]:flex', 'noscript:flex'],
|
||||
|
||||
// With compound variants
|
||||
['has-[&:focus]:flex', 'has-focus:flex'],
|
||||
['not-[&:focus]:flex', 'not-focus:flex'],
|
||||
['group-[&:focus]:flex', 'group-focus:flex'],
|
||||
['peer-[&:focus]:flex', 'peer-focus:flex'],
|
||||
['in-[&:focus]:flex', 'in-focus:flex'],
|
||||
])(testName, async (candidate, result) => {
|
||||
if (strategy === 'important') {
|
||||
candidate = `${candidate}!`
|
||||
result = `${result}!`
|
||||
} else if (strategy === 'prefix') {
|
||||
// Not only do we need to prefix the candidate, we also have to make
|
||||
// sure that we prefix all CSS variables.
|
||||
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
|
||||
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
|
||||
}
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
})
|
||||
|
||||
test('unsafe migrations keep the candidate as-is', async () => {
|
||||
// `hover:` also includes an `@media` query in addition to the `&:hover`
|
||||
// state. Migration is not safe because the functionality would be different.
|
||||
let candidate = '[&:hover]:flex'
|
||||
let result = '[&:hover]:flex'
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
`
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
test('make unsafe migration safe (1)', async () => {
|
||||
// Overriding the `hover:` variant to only use a selector will make the
|
||||
// migration safe.
|
||||
let candidate = '[&:hover]:flex'
|
||||
let result = 'hover:flex'
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
@variant hover (&:hover);
|
||||
`
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
test('make unsafe migration safe (2)', async () => {
|
||||
// Overriding the `hover:` variant to only use a selector will make the
|
||||
// migration safe. This time with the long-hand `@variant` syntax.
|
||||
let candidate = '[&:hover]:flex'
|
||||
let result = 'hover:flex'
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
@variant hover {
|
||||
&:hover {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
test('custom selector-based variants', async () => {
|
||||
let candidate = '[&.macos]:flex'
|
||||
let result = 'is-macos:flex'
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
@variant is-macos (&.macos);
|
||||
`
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
|
||||
test('custom @media-based variants', async () => {
|
||||
let candidate = '[@media(prefers-reduced-transparency:reduce)]:flex'
|
||||
let result = 'transparency-safe:flex'
|
||||
let input = css`
|
||||
@import 'tailwindcss';
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
@variant transparency-safe {
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = migrateArbitraryVariants(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
@ -0,0 +1,64 @@
|
||||
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 { replaceObject } from '../../utils/replace-object'
|
||||
import type { Writable } from '../../utils/types'
|
||||
import { walkVariants } from '../../utils/walk-variants'
|
||||
import { computeVariantSignature } from './signatures'
|
||||
|
||||
const variantsLookup = new DefaultMap<DesignSystem, DefaultMap<string, string[]>>(
|
||||
(designSystem) => {
|
||||
let signatures = computeVariantSignature.get(designSystem)
|
||||
let lookup = new DefaultMap<string, string[]>(() => [])
|
||||
|
||||
// Actual static variants
|
||||
for (let [root, variant] of designSystem.variants.entries()) {
|
||||
if (variant.kind === 'static') {
|
||||
let signature = signatures.get(root)
|
||||
if (typeof signature !== 'string') continue
|
||||
lookup.get(signature).push(root)
|
||||
}
|
||||
}
|
||||
|
||||
return lookup
|
||||
},
|
||||
)
|
||||
|
||||
export function migrateArbitraryVariants(
|
||||
designSystem: DesignSystem,
|
||||
_userConfig: Config | null,
|
||||
rawCandidate: string,
|
||||
): string {
|
||||
let signatures = computeVariantSignature.get(designSystem)
|
||||
let variants = variantsLookup.get(designSystem)
|
||||
|
||||
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
|
||||
// We are only interested in the variants
|
||||
if (readonlyCandidate.variants.length <= 0) return rawCandidate
|
||||
|
||||
// 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>
|
||||
|
||||
for (let [variant] of walkVariants(candidate)) {
|
||||
if (variant.kind === 'compound') continue
|
||||
|
||||
let targetString = designSystem.printVariant(variant)
|
||||
let targetSignature = signatures.get(targetString)
|
||||
if (typeof targetSignature !== 'string') continue
|
||||
|
||||
let foundVariants = variants.get(targetSignature)
|
||||
if (foundVariants.length !== 1) continue
|
||||
|
||||
let foundVariant = foundVariants[0]
|
||||
let parsedVariant = designSystem.parseVariant(foundVariant)
|
||||
if (parsedVariant === null) continue
|
||||
|
||||
replaceObject(variant, parsedVariant)
|
||||
}
|
||||
|
||||
return designSystem.printCandidate(candidate)
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
}
|
||||
@ -2,7 +2,6 @@ import { walk, WalkAction } from '../../../../tailwindcss/src/ast'
|
||||
import { type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
export function migrateAutomaticVarInjection(
|
||||
designSystem: DesignSystem,
|
||||
@ -66,7 +65,7 @@ export function migrateAutomaticVarInjection(
|
||||
}
|
||||
|
||||
if (didChange) {
|
||||
return printCandidate(designSystem, candidate)
|
||||
return designSystem.printCandidate(candidate)
|
||||
}
|
||||
}
|
||||
return rawCandidate
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl']
|
||||
|
||||
@ -17,7 +16,7 @@ export function migrateBgGradient(
|
||||
continue
|
||||
}
|
||||
|
||||
return printCandidate(designSystem, {
|
||||
return designSystem.printCandidate({
|
||||
...candidate,
|
||||
root: `bg-linear-to-${direction}`,
|
||||
})
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
||||
import { migrateDropUnnecessaryDataTypes } from './migrate-drop-unnecessary-data-types'
|
||||
|
||||
const css = String.raw
|
||||
|
||||
const designSystems = new DefaultMap((base: string) => {
|
||||
return new DefaultMap((input: string) => {
|
||||
return __unstable__loadDesignSystem(input, { base })
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
|
||||
let testName = '%s => %s (%#)'
|
||||
if (strategy === 'with-variant') {
|
||||
testName = testName.replaceAll('%s', 'focus:%s')
|
||||
} else if (strategy === 'important') {
|
||||
testName = testName.replaceAll('%s', '%s!')
|
||||
} else if (strategy === 'prefix') {
|
||||
testName = testName.replaceAll('%s', 'tw:%s')
|
||||
}
|
||||
|
||||
// Basic input with minimal design system to keep the tests fast
|
||||
let input = css`
|
||||
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
|
||||
@theme {
|
||||
--*: initial;
|
||||
}
|
||||
`
|
||||
|
||||
test.each([
|
||||
// A color value can be inferred from the value
|
||||
['bg-[color:#008cc]', 'bg-[#008cc]'],
|
||||
|
||||
// A position can be inferred from the value
|
||||
['bg-[position:123px]', 'bg-[123px]'],
|
||||
|
||||
// A color is the default for `bg-*`
|
||||
['bg-(color:--my-value)', 'bg-(--my-value)'],
|
||||
|
||||
// A position is not the default, so the `position` data type is kept
|
||||
['bg-(position:--my-value)', 'bg-(position:--my-value)'],
|
||||
])(testName, async (candidate, expected) => {
|
||||
if (strategy === 'with-variant') {
|
||||
candidate = `focus:${candidate}`
|
||||
expected = `focus:${expected}`
|
||||
} else if (strategy === 'important') {
|
||||
candidate = `${candidate}!`
|
||||
expected = `${expected}!`
|
||||
} else if (strategy === 'prefix') {
|
||||
// Not only do we need to prefix the candidate, we also have to make
|
||||
// sure that we prefix all CSS variables.
|
||||
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
|
||||
expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}`
|
||||
}
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
|
||||
let migrated = migrateDropUnnecessaryDataTypes(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,30 @@
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { computeUtilitySignature } from './signatures'
|
||||
|
||||
export function migrateDropUnnecessaryDataTypes(
|
||||
designSystem: DesignSystem,
|
||||
_userConfig: Config | null,
|
||||
rawCandidate: string,
|
||||
): string {
|
||||
let signatures = computeUtilitySignature.get(designSystem)
|
||||
|
||||
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
|
||||
if (
|
||||
candidate.kind === 'functional' &&
|
||||
candidate.value?.kind === 'arbitrary' &&
|
||||
candidate.value.dataType !== null
|
||||
) {
|
||||
let replacement = designSystem.printCandidate({
|
||||
...candidate,
|
||||
value: { ...candidate.value, dataType: null },
|
||||
})
|
||||
|
||||
if (signatures.get(rawCandidate) === signatures.get(replacement)) {
|
||||
return replacement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { printCandidate } from './candidates'
|
||||
import { isSafeMigration } from './is-safe-migration'
|
||||
|
||||
// In v3 the important modifier `!` sits in front of the utility itself, not
|
||||
@ -41,7 +40,7 @@ export function migrateImportant(
|
||||
// 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(designSystem, candidate)
|
||||
return designSystem.printCandidate(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { segment } from '../../../../tailwindcss/src/utils/segment'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
export function migrateLegacyArbitraryValues(
|
||||
designSystem: DesignSystem,
|
||||
@ -23,7 +22,7 @@ export function migrateLegacyArbitraryValues(
|
||||
clone.value.value = segment(clone.value.value, ',').join(' ')
|
||||
}
|
||||
|
||||
return changed ? printCandidate(designSystem, clone) : rawCandidate
|
||||
return changed ? designSystem.printCandidate(clone) : rawCandidate
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
|
||||
@ -6,7 +6,6 @@ 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 * as version from '../../utils/version'
|
||||
import { printCandidate } from './candidates'
|
||||
import { isSafeMigration } from './is-safe-migration'
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url)
|
||||
@ -94,7 +93,7 @@ export async function migrateLegacyClasses(
|
||||
let baseCandidate = structuredClone(candidate) as Candidate
|
||||
baseCandidate.variants = []
|
||||
baseCandidate.important = false
|
||||
let baseCandidateString = printCandidate(designSystem, baseCandidate)
|
||||
let baseCandidateString = designSystem.printCandidate(baseCandidate)
|
||||
|
||||
// Find the new base candidate string. `blur` -> `blur-sm`
|
||||
let newBaseCandidateString = LEGACY_CLASS_MAP.get(baseCandidateString)
|
||||
@ -171,7 +170,7 @@ export async function migrateLegacyClasses(
|
||||
}
|
||||
}
|
||||
|
||||
return printCandidate(designSystem, toCandidate)
|
||||
return designSystem.printCandidate(toCandidate)
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
export function migrateMaxWidthScreen(
|
||||
designSystem: DesignSystem,
|
||||
@ -13,7 +12,7 @@ export function migrateMaxWidthScreen(
|
||||
candidate.root === 'max-w' &&
|
||||
candidate.value?.value.startsWith('screen-')
|
||||
) {
|
||||
return printCandidate(designSystem, {
|
||||
return designSystem.printCandidate({
|
||||
...candidate,
|
||||
value: {
|
||||
...candidate.value,
|
||||
|
||||
@ -1,11 +1,26 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import type { UserConfig } from '../../../../tailwindcss/src/compat/config/types'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import * as versions from '../../utils/version'
|
||||
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
|
||||
import { migrateEmptyArbitraryValues } from './migrate-handle-empty-arbitrary-values'
|
||||
import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-values'
|
||||
import { migratePrefix } from './migrate-prefix'
|
||||
vi.spyOn(versions, 'isMajor').mockReturnValue(true)
|
||||
|
||||
function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawCandidate: string) {
|
||||
for (let migration of [
|
||||
migrateEmptyArbitraryValues,
|
||||
migratePrefix,
|
||||
migrateModernizeArbitraryValues,
|
||||
migrateArbitraryVariants,
|
||||
]) {
|
||||
rawCandidate = migration(designSystem, userConfig, rawCandidate)
|
||||
}
|
||||
return rawCandidate
|
||||
}
|
||||
|
||||
test.each([
|
||||
// Arbitrary variants
|
||||
['[[data-visible]]:flex', 'data-visible:flex'],
|
||||
@ -72,6 +87,9 @@ test.each([
|
||||
// Keep multiple attribute selectors as-is
|
||||
['[[data-visible][data-dark]]:flex', '[[data-visible][data-dark]]:flex'],
|
||||
|
||||
// Keep `:where(…)` as is
|
||||
['[:where([data-visible])]:flex', '[:where([data-visible])]:flex'],
|
||||
|
||||
// Complex attribute selectors with operators, quotes and insensitivity flags
|
||||
['[[data-url*="example"]]:flex', 'data-[url*="example"]:flex'],
|
||||
['[[data-url$=".com"_i]]:flex', 'data-[url$=".com"_i]:flex'],
|
||||
@ -87,6 +105,13 @@ test.each([
|
||||
['[@media_print]:flex', 'print:flex'],
|
||||
['[@media_not_print]:flex', 'not-print:flex'],
|
||||
|
||||
// Hoist the `:not` part to a compound variant
|
||||
['[@media_not_(prefers-color-scheme:dark)]:flex', 'not-dark:flex'],
|
||||
[
|
||||
'[@media_not_(prefers-color-scheme:unknown)]:flex',
|
||||
'not-[@media_(prefers-color-scheme:unknown)]:flex',
|
||||
],
|
||||
|
||||
// Compound arbitrary variants
|
||||
['has-[[data-visible]]:flex', 'has-data-visible:flex'],
|
||||
['has-[&:is([data-visible])]:flex', 'has-data-visible:flex'],
|
||||
@ -104,12 +129,7 @@ test.each([
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
expect(
|
||||
[migrateEmptyArbitraryValues, migrateModernizeArbitraryValues].reduce(
|
||||
(acc, step) => step(designSystem, {}, acc),
|
||||
candidate,
|
||||
),
|
||||
).toEqual(result)
|
||||
expect(migrate(designSystem, {}, candidate)).toEqual(result)
|
||||
})
|
||||
|
||||
test.each([
|
||||
@ -138,10 +158,5 @@ test.each([
|
||||
base: __dirname,
|
||||
})
|
||||
|
||||
expect(
|
||||
[migrateEmptyArbitraryValues, migratePrefix, migrateModernizeArbitraryValues].reduce(
|
||||
(acc, step) => step(designSystem, { prefix: 'tw-' }, acc),
|
||||
candidate,
|
||||
),
|
||||
).toEqual(result)
|
||||
expect(migrate(designSystem, { prefix: 'tw-' }, candidate)).toEqual(result)
|
||||
})
|
||||
|
||||
@ -1,29 +1,25 @@
|
||||
import SelectorParser from 'postcss-selector-parser'
|
||||
import { parseCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
|
||||
import { parseCandidate, type Variant } from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { isPositiveInteger } from '../../../../tailwindcss/src/utils/infer-data-type'
|
||||
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
function memcpy<T extends object, U extends object | null>(target: T, source: U): U {
|
||||
// Clear out the target object, otherwise inspecting the final object will
|
||||
// look very confusing.
|
||||
for (let key in target) delete target[key]
|
||||
|
||||
return Object.assign(target, source)
|
||||
}
|
||||
import { replaceObject } from '../../utils/replace-object'
|
||||
import { walkVariants } from '../../utils/walk-variants'
|
||||
import { computeVariantSignature } from './signatures'
|
||||
|
||||
export function migrateModernizeArbitraryValues(
|
||||
designSystem: DesignSystem,
|
||||
_userConfig: Config | null,
|
||||
rawCandidate: string,
|
||||
): string {
|
||||
let signatures = computeVariantSignature.get(designSystem)
|
||||
|
||||
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
|
||||
let clone = structuredClone(candidate)
|
||||
let changed = false
|
||||
|
||||
for (let [variant, parent] of variants(clone)) {
|
||||
for (let [variant, parent] of walkVariants(clone)) {
|
||||
// Forward modifier from the root to the compound variant
|
||||
if (
|
||||
variant.kind === 'compound' &&
|
||||
@ -49,7 +45,7 @@ export function migrateModernizeArbitraryValues(
|
||||
// `group-[]`
|
||||
if (variant.modifier === null) {
|
||||
changed = true
|
||||
memcpy(
|
||||
replaceObject(
|
||||
variant,
|
||||
designSystem.parseVariant(
|
||||
designSystem.theme.prefix
|
||||
@ -62,7 +58,7 @@ export function migrateModernizeArbitraryValues(
|
||||
// `group-[]/name`
|
||||
else if (variant.modifier.kind === 'named') {
|
||||
changed = true
|
||||
memcpy(
|
||||
replaceObject(
|
||||
variant,
|
||||
designSystem.parseVariant(
|
||||
designSystem.theme.prefix
|
||||
@ -98,7 +94,7 @@ export function migrateModernizeArbitraryValues(
|
||||
ast.nodes[0].nodes[2].type === 'universal'
|
||||
) {
|
||||
changed = true
|
||||
memcpy(variant, designSystem.parseVariant('*'))
|
||||
replaceObject(variant, designSystem.parseVariant('*'))
|
||||
continue
|
||||
}
|
||||
|
||||
@ -116,7 +112,7 @@ export function migrateModernizeArbitraryValues(
|
||||
ast.nodes[0].nodes[2].type === 'universal'
|
||||
) {
|
||||
changed = true
|
||||
memcpy(variant, designSystem.parseVariant('**'))
|
||||
replaceObject(variant, designSystem.parseVariant('**'))
|
||||
continue
|
||||
}
|
||||
|
||||
@ -143,109 +139,53 @@ export function migrateModernizeArbitraryValues(
|
||||
// that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`.
|
||||
//
|
||||
// Later this gets converted to `in-data-visible`.
|
||||
memcpy(variant, designSystem.parseVariant(`in-[${ast.toString()}]`))
|
||||
replaceObject(variant, designSystem.parseVariant(`in-[${ast.toString()}]`))
|
||||
continue
|
||||
}
|
||||
|
||||
// Migrate `@media` variants
|
||||
// Hoist `not` modifier for `@media` or `@supports` variants
|
||||
//
|
||||
// E.g.: `[@media(scripting:none)]:` -> `noscript:`
|
||||
// E.g.: `[@media_not_(scripting:none)]:` -> `not-[@media_(scripting:none)]:`
|
||||
if (
|
||||
// Only top-level, so something like `in-[@media(scripting:none)]`
|
||||
// (which is not valid anyway) is not supported
|
||||
parent === null &&
|
||||
// [@media(scripting:none)]:flex
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^
|
||||
// [@media_not(scripting:none)]:flex
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
ast.nodes[0].nodes[0].type === 'tag' &&
|
||||
ast.nodes[0].nodes[0].value.startsWith('@media')
|
||||
(ast.nodes[0].nodes[0].value.startsWith('@media') ||
|
||||
ast.nodes[0].nodes[0].value.startsWith('@supports'))
|
||||
) {
|
||||
// Replace all whitespace such that `@media (scripting: none)` and
|
||||
// `@media(scripting:none)` are equivalent.
|
||||
//
|
||||
// As arbitrary variants that means that these are equivalent:
|
||||
// - `[@media_(scripting:_none)]:`
|
||||
// - `[@media(scripting:none)]:`
|
||||
let parsed = ValueParser.parse(ast.nodes[0].toString().trim().replace('@media', ''))
|
||||
|
||||
// Drop whitespace
|
||||
let targetSignature = signatures.get(designSystem.printVariant(variant))
|
||||
let parsed = ValueParser.parse(ast.nodes[0].toString().trim())
|
||||
let containsNot = false
|
||||
ValueParser.walk(parsed, (node, { replaceWith }) => {
|
||||
// Drop whitespace nodes
|
||||
if (node.kind === 'separator' && !node.value.trim()) {
|
||||
if (node.kind === 'word' && node.value === 'not') {
|
||||
containsNot = true
|
||||
replaceWith([])
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
else {
|
||||
node.value = node.value.trim()
|
||||
}
|
||||
})
|
||||
|
||||
let not = false
|
||||
if (parsed[0]?.kind === 'word' && parsed[0].value === 'not') {
|
||||
not = true
|
||||
parsed.shift()
|
||||
}
|
||||
// Remove unnecessary whitespace
|
||||
parsed = ValueParser.parse(ValueParser.toCss(parsed))
|
||||
ValueParser.walk(parsed, (node) => {
|
||||
if (node.kind === 'separator' && node.value !== ' ' && node.value.trim() === '') {
|
||||
// node.value contains at least 2 spaces. Normalize it to a single
|
||||
// space.
|
||||
node.value = ' '
|
||||
}
|
||||
})
|
||||
|
||||
// Single keyword at-rules.
|
||||
//
|
||||
// E.g.: `[@media_print]:` -< `@media print` -> `print:`
|
||||
if (parsed.length === 1 && parsed[0].kind === 'word') {
|
||||
let key = parsed[0].value
|
||||
let replacement: string | null = null
|
||||
if (key === 'print') replacement = 'print'
|
||||
|
||||
if (replacement) {
|
||||
if (containsNot) {
|
||||
let hoistedNot = designSystem.parseVariant(`not-[${ValueParser.toCss(parsed)}]`)
|
||||
if (hoistedNot === null) continue
|
||||
let hoistedNotSignature = signatures.get(designSystem.printVariant(hoistedNot))
|
||||
if (targetSignature === hoistedNotSignature) {
|
||||
changed = true
|
||||
memcpy(variant, designSystem.parseVariant(`${not ? 'not-' : ''}${replacement}`))
|
||||
replaceObject(variant, hoistedNot)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Key/value at-rules.
|
||||
//
|
||||
// E.g.: `[@media(scripting:none)]:` -> `scripting:`
|
||||
if (
|
||||
parsed.length === 1 &&
|
||||
parsed[0].kind === 'function' && // `(` and `)` are considered a function
|
||||
parsed[0].nodes.length === 3 &&
|
||||
parsed[0].nodes[0].kind === 'word' &&
|
||||
parsed[0].nodes[1].kind === 'separator' &&
|
||||
parsed[0].nodes[1].value === ':' &&
|
||||
parsed[0].nodes[2].kind === 'word'
|
||||
) {
|
||||
let key = parsed[0].nodes[0].value
|
||||
let value = parsed[0].nodes[2].value
|
||||
let replacement: string | null = null
|
||||
|
||||
if (key === 'prefers-reduced-motion' && value === 'no-preference')
|
||||
replacement = 'motion-safe'
|
||||
if (key === 'prefers-reduced-motion' && value === 'reduce')
|
||||
replacement = 'motion-reduce'
|
||||
|
||||
if (key === 'prefers-contrast' && value === 'more') replacement = 'contrast-more'
|
||||
if (key === 'prefers-contrast' && value === 'less') replacement = 'contrast-less'
|
||||
|
||||
if (key === 'orientation' && value === 'portrait') replacement = 'portrait'
|
||||
if (key === 'orientation' && value === 'landscape') replacement = 'landscape'
|
||||
|
||||
if (key === 'forced-colors' && value === 'active') replacement = 'forced-colors'
|
||||
|
||||
if (key === 'inverted-colors' && value === 'inverted') replacement = 'inverted-colors'
|
||||
|
||||
if (key === 'pointer' && value === 'none') replacement = 'pointer-none'
|
||||
if (key === 'pointer' && value === 'coarse') replacement = 'pointer-coarse'
|
||||
if (key === 'pointer' && value === 'fine') replacement = 'pointer-fine'
|
||||
if (key === 'any-pointer' && value === 'none') replacement = 'any-pointer-none'
|
||||
if (key === 'any-pointer' && value === 'coarse') replacement = 'any-pointer-coarse'
|
||||
if (key === 'any-pointer' && value === 'fine') replacement = 'any-pointer-fine'
|
||||
|
||||
if (key === 'scripting' && value === 'none') replacement = 'noscript'
|
||||
|
||||
if (replacement) {
|
||||
changed = true
|
||||
memcpy(variant, designSystem.parseVariant(`${not ? 'not-' : ''}${replacement}`))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let prefixedVariant: Variant | null = null
|
||||
@ -317,48 +257,6 @@ export function migrateModernizeArbitraryValues(
|
||||
}
|
||||
|
||||
let newVariant = ((value) => {
|
||||
//
|
||||
if (value === ':first-letter') return 'first-letter'
|
||||
else if (value === ':first-line') return 'first-line'
|
||||
//
|
||||
else if (value === ':file-selector-button') return 'file'
|
||||
else if (value === ':placeholder') return 'placeholder'
|
||||
else if (value === ':backdrop') return 'backdrop'
|
||||
// Positional
|
||||
else if (value === ':first-child') return 'first'
|
||||
else if (value === ':last-child') return 'last'
|
||||
else if (value === ':only-child') return 'only'
|
||||
else if (value === ':first-of-type') return 'first-of-type'
|
||||
else if (value === ':last-of-type') return 'last-of-type'
|
||||
else if (value === ':only-of-type') return 'only-of-type'
|
||||
// State
|
||||
else if (value === ':visited') return 'visited'
|
||||
else if (value === ':target') return 'target'
|
||||
// Forms
|
||||
else if (value === ':default') return 'default'
|
||||
else if (value === ':checked') return 'checked'
|
||||
else if (value === ':indeterminate') return 'indeterminate'
|
||||
else if (value === ':placeholder-shown') return 'placeholder-shown'
|
||||
else if (value === ':autofill') return 'autofill'
|
||||
else if (value === ':optional') return 'optional'
|
||||
else if (value === ':required') return 'required'
|
||||
else if (value === ':valid') return 'valid'
|
||||
else if (value === ':invalid') return 'invalid'
|
||||
else if (value === ':user-valid') return 'user-valid'
|
||||
else if (value === ':user-invalid') return 'user-invalid'
|
||||
else if (value === ':in-range') return 'in-range'
|
||||
else if (value === ':out-of-range') return 'out-of-range'
|
||||
else if (value === ':read-only') return 'read-only'
|
||||
// Content
|
||||
else if (value === ':empty') return 'empty'
|
||||
// Interactive
|
||||
else if (value === ':focus-within') return 'focus-within'
|
||||
else if (value === ':focus') return 'focus'
|
||||
else if (value === ':focus-visible') return 'focus-visible'
|
||||
else if (value === ':active') return 'active'
|
||||
else if (value === ':enabled') return 'enabled'
|
||||
else if (value === ':disabled') return 'disabled'
|
||||
//
|
||||
if (
|
||||
value === ':nth-child' &&
|
||||
targetNode.nodes.length === 1 &&
|
||||
@ -405,6 +303,15 @@ export function migrateModernizeArbitraryValues(
|
||||
}
|
||||
}
|
||||
|
||||
// Hoist `not` modifier
|
||||
if (compoundNot) {
|
||||
let targetSignature = signatures.get(designSystem.printVariant(variant))
|
||||
let replacementSignature = signatures.get(`not-[${value}]`)
|
||||
if (targetSignature === replacementSignature) {
|
||||
return `[&${value}]`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})(targetNode.value)
|
||||
|
||||
@ -418,7 +325,7 @@ export function migrateModernizeArbitraryValues(
|
||||
|
||||
// Update original variant
|
||||
changed = true
|
||||
memcpy(variant, parsed)
|
||||
replaceObject(variant, structuredClone(parsed))
|
||||
}
|
||||
|
||||
// Expecting an attribute selector
|
||||
@ -443,7 +350,7 @@ export function migrateModernizeArbitraryValues(
|
||||
if (attributeKey.startsWith('data-')) {
|
||||
changed = true
|
||||
attributeKey = attributeKey.slice(5) // Remove `data-`
|
||||
memcpy(variant, {
|
||||
replaceObject(variant, {
|
||||
kind: 'functional',
|
||||
root: 'data',
|
||||
modifier: null,
|
||||
@ -458,7 +365,7 @@ export function migrateModernizeArbitraryValues(
|
||||
else if (attributeKey.startsWith('aria-')) {
|
||||
changed = true
|
||||
attributeKey = attributeKey.slice(5) // Remove `aria-`
|
||||
memcpy(variant, {
|
||||
replaceObject(variant, {
|
||||
kind: 'functional',
|
||||
root: 'aria',
|
||||
modifier: null,
|
||||
@ -482,25 +389,8 @@ export function migrateModernizeArbitraryValues(
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? printCandidate(designSystem, clone) : rawCandidate
|
||||
return changed ? designSystem.printCandidate(clone) : rawCandidate
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
}
|
||||
|
||||
function* variants(candidate: Candidate) {
|
||||
function* inner(
|
||||
variant: Variant,
|
||||
parent: Extract<Variant, { kind: 'compound' }> | null = null,
|
||||
): Iterable<[Variant, Extract<Variant, { kind: 'compound' }> | null]> {
|
||||
yield [variant, parent]
|
||||
|
||||
if (variant.kind === 'compound') {
|
||||
yield* inner(variant.variant, variant)
|
||||
}
|
||||
}
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
yield* inner(variant, null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
||||
import { migrateOptimizeModifier } from './migrate-optimize-modifier'
|
||||
|
||||
const css = String.raw
|
||||
|
||||
const designSystems = new DefaultMap((base: string) => {
|
||||
return new DefaultMap((input: string) => {
|
||||
return __unstable__loadDesignSystem(input, { base })
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
|
||||
let testName = '%s => %s (%#)'
|
||||
if (strategy === 'with-variant') {
|
||||
testName = testName.replaceAll('%s', 'focus:%s')
|
||||
} else if (strategy === 'important') {
|
||||
testName = testName.replaceAll('%s', '%s!')
|
||||
} else if (strategy === 'prefix') {
|
||||
testName = testName.replaceAll('%s', 'tw:%s')
|
||||
}
|
||||
|
||||
// Basic input with minimal design system to keep the tests fast
|
||||
let input = css`
|
||||
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
|
||||
@theme {
|
||||
--*: initial;
|
||||
--color-red-500: red;
|
||||
}
|
||||
`
|
||||
|
||||
test.each([
|
||||
// Keep the modifier as-is, nothing to optimize
|
||||
['bg-red-500/25', 'bg-red-500/25'],
|
||||
|
||||
// Use a bare value modifier
|
||||
['bg-red-500/[25%]', 'bg-red-500/25'],
|
||||
|
||||
// Drop unnecessary modifiers
|
||||
['bg-red-500/[100%]', 'bg-red-500'],
|
||||
['bg-red-500/100', 'bg-red-500'],
|
||||
|
||||
// Keep modifiers on classes that don't _really_ exist
|
||||
['group/name', 'group/name'],
|
||||
])(testName, async (candidate, expected) => {
|
||||
if (strategy === 'with-variant') {
|
||||
candidate = `focus:${candidate}`
|
||||
expected = `focus:${expected}`
|
||||
} else if (strategy === 'important') {
|
||||
candidate = `${candidate}!`
|
||||
expected = `${expected}!`
|
||||
} else if (strategy === 'prefix') {
|
||||
// Not only do we need to prefix the candidate, we also have to make
|
||||
// sure that we prefix all CSS variables.
|
||||
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
|
||||
expected = `tw:${expected.replaceAll('var(--', 'var(--tw-')}`
|
||||
}
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
|
||||
let migrated = migrateOptimizeModifier(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,63 @@
|
||||
import type { NamedUtilityValue } from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import type { Writable } from '../../utils/types'
|
||||
import { computeUtilitySignature } from './signatures'
|
||||
|
||||
// Optimize the modifier
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// - `/[25%]` → `/25`
|
||||
// - `/[100%]` → `/100` → <empty>
|
||||
// - `/100` → <empty>
|
||||
//
|
||||
export function migrateOptimizeModifier(
|
||||
designSystem: DesignSystem,
|
||||
_userConfig: Config | null,
|
||||
rawCandidate: string,
|
||||
): string {
|
||||
let signatures = computeUtilitySignature.get(designSystem)
|
||||
|
||||
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
|
||||
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
|
||||
if (
|
||||
(candidate.kind === 'functional' && candidate.modifier !== null) ||
|
||||
(candidate.kind === 'arbitrary' && candidate.modifier !== null)
|
||||
) {
|
||||
let targetSignature = signatures.get(rawCandidate)
|
||||
let modifier = candidate.modifier
|
||||
let changed = false
|
||||
|
||||
// 1. Try to drop the modifier entirely
|
||||
if (
|
||||
targetSignature ===
|
||||
signatures.get(designSystem.printCandidate({ ...candidate, modifier: null }))
|
||||
) {
|
||||
changed = true
|
||||
candidate.modifier = null
|
||||
}
|
||||
|
||||
// 2. Try to remove the square brackets and the `%` sign
|
||||
if (!changed) {
|
||||
let newModifier: NamedUtilityValue = {
|
||||
kind: 'named',
|
||||
value: modifier.value.endsWith('%') ? modifier.value.slice(0, -1) : modifier.value,
|
||||
fraction: null,
|
||||
}
|
||||
|
||||
if (
|
||||
targetSignature ===
|
||||
signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier }))
|
||||
) {
|
||||
changed = true
|
||||
candidate.modifier = newModifier
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? designSystem.printCandidate(candidate) : rawCandidate
|
||||
}
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
}
|
||||
@ -3,7 +3,6 @@ import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { segment } from '../../../../tailwindcss/src/utils/segment'
|
||||
import * as version from '../../utils/version'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
let seenDesignSystems = new WeakSet<DesignSystem>()
|
||||
|
||||
@ -48,7 +47,7 @@ export function migratePrefix(
|
||||
|
||||
if (!candidate) return rawCandidate
|
||||
|
||||
return printCandidate(designSystem, candidate)
|
||||
return designSystem.printCandidate(candidate)
|
||||
}
|
||||
|
||||
// Parses a raw candidate with v3 compatible prefix syntax. This won't match if
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import * as version from '../../utils/version'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
// Classes that used to exist in Tailwind CSS v3, but do not exist in Tailwind
|
||||
// CSS v4 anymore.
|
||||
@ -53,7 +52,7 @@ export function migrateSimpleLegacyClasses(
|
||||
|
||||
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
|
||||
if (candidate.kind === 'static' && Object.hasOwn(LEGACY_CLASS_MAP, candidate.root)) {
|
||||
return printCandidate(designSystem, {
|
||||
return designSystem.printCandidate({
|
||||
...candidate,
|
||||
root: LEGACY_CLASS_MAP[candidate.root as keyof typeof LEGACY_CLASS_MAP],
|
||||
})
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
import {
|
||||
parseCandidate,
|
||||
type Candidate,
|
||||
type CandidateModifier,
|
||||
type Variant,
|
||||
} from '../../../../tailwindcss/src/candidate'
|
||||
import { parseCandidate, type CandidateModifier } from '../../../../tailwindcss/src/candidate'
|
||||
import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
@ -11,7 +6,7 @@ import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infe
|
||||
import { segment } from '../../../../tailwindcss/src/utils/segment'
|
||||
import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path'
|
||||
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
|
||||
import { printCandidate } from './candidates'
|
||||
import { walkVariants } from '../../utils/walk-variants'
|
||||
|
||||
export const enum Convert {
|
||||
All = 0,
|
||||
@ -59,7 +54,7 @@ export function migrateThemeToVar(
|
||||
}
|
||||
|
||||
// Handle variants
|
||||
for (let variant of variants(clone)) {
|
||||
for (let [variant] of walkVariants(clone)) {
|
||||
if (variant.kind === 'arbitrary') {
|
||||
let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly)
|
||||
if (newValue !== variant.selector) {
|
||||
@ -75,7 +70,7 @@ export function migrateThemeToVar(
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? printCandidate(designSystem, clone) : rawCandidate
|
||||
return changed ? designSystem.printCandidate(clone) : rawCandidate
|
||||
}
|
||||
|
||||
return rawCandidate
|
||||
@ -332,16 +327,3 @@ function eventuallyUnquote(value: string) {
|
||||
|
||||
return unquoted
|
||||
}
|
||||
|
||||
function* variants(candidate: Candidate) {
|
||||
function* inner(variant: Variant): Iterable<Variant> {
|
||||
yield variant
|
||||
if (variant.kind === 'compound') {
|
||||
yield* inner(variant.variant)
|
||||
}
|
||||
}
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
yield* inner(variant)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import { type Variant } from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import * as version from '../../utils/version'
|
||||
import { printCandidate } from './candidates'
|
||||
|
||||
export function migrateVariantOrder(
|
||||
designSystem: DesignSystem,
|
||||
@ -56,7 +55,7 @@ export function migrateVariantOrder(
|
||||
continue
|
||||
}
|
||||
|
||||
return printCandidate(designSystem, { ...candidate, variants: newOrder })
|
||||
return designSystem.printCandidate({ ...candidate, variants: newOrder })
|
||||
}
|
||||
return rawCandidate
|
||||
}
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
||||
import * as versions from '../../utils/version'
|
||||
import { migrateCandidate as migrate } from './migrate'
|
||||
vi.spyOn(versions, 'isMajor').mockReturnValue(false)
|
||||
|
||||
const designSystems = new DefaultMap((base: string) => {
|
||||
return new DefaultMap((input: string) => {
|
||||
return __unstable__loadDesignSystem(input, { base })
|
||||
})
|
||||
})
|
||||
|
||||
const css = String.raw
|
||||
|
||||
describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => {
|
||||
let testName = '%s => %s (%#)'
|
||||
if (strategy === 'with-variant') {
|
||||
testName = testName.replaceAll('%s', 'focus:%s')
|
||||
} else if (strategy === 'important') {
|
||||
testName = testName.replaceAll('%s', '%s!')
|
||||
} else if (strategy === 'prefix') {
|
||||
testName = testName.replaceAll('%s', 'tw:%s')
|
||||
}
|
||||
|
||||
// Basic input with minimal design system to keep the tests fast
|
||||
let input = css`
|
||||
@import 'tailwindcss' ${strategy === 'prefix' ? 'prefix(tw)' : ''};
|
||||
@theme {
|
||||
--*: initial;
|
||||
--spacing: 0.25rem;
|
||||
--color-red-500: red;
|
||||
|
||||
/* Equivalent of blue-500/50 */
|
||||
--color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent);
|
||||
}
|
||||
`
|
||||
|
||||
test.each([
|
||||
// Arbitrary property to named functional utlity
|
||||
['[color:red]', 'text-red-500'],
|
||||
|
||||
// Promote data types to more specific utility if it exists
|
||||
['bg-(position:--my-value)', 'bg-position-(--my-value)'],
|
||||
|
||||
// Promote inferred data type to more specific utility if it exists
|
||||
['bg-[123px]', 'bg-position-[123px]'],
|
||||
])(testName, async (candidate, result) => {
|
||||
if (strategy === 'with-variant') {
|
||||
candidate = `focus:${candidate}`
|
||||
result = `focus:${result}`
|
||||
} else if (strategy === 'important') {
|
||||
candidate = `${candidate}!`
|
||||
result = `${result}!`
|
||||
} else if (strategy === 'prefix') {
|
||||
// Not only do we need to prefix the candidate, we also have to make
|
||||
// sure that we prefix all CSS variables.
|
||||
candidate = `tw:${candidate.replaceAll('var(--', 'var(--tw-')}`
|
||||
result = `tw:${result.replaceAll('var(--', 'var(--tw-')}`
|
||||
}
|
||||
|
||||
let designSystem = await designSystems.get(__dirname).get(input)
|
||||
let migrated = await migrate(designSystem, {}, candidate)
|
||||
expect(migrated).toEqual(result)
|
||||
})
|
||||
})
|
||||
@ -4,16 +4,20 @@ import { parseCandidate } from '../../../../tailwindcss/src/candidate'
|
||||
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { spliceChangesIntoString, type StringChange } from '../../utils/splice-changes-into-string'
|
||||
import { extractRawCandidates, printCandidate } from './candidates'
|
||||
import { extractRawCandidates } from './candidates'
|
||||
import { migrateArbitraryUtilities } from './migrate-arbitrary-utilities'
|
||||
import { migrateArbitraryValueToBareValue } from './migrate-arbitrary-value-to-bare-value'
|
||||
import { migrateArbitraryVariants } from './migrate-arbitrary-variants'
|
||||
import { migrateAutomaticVarInjection } from './migrate-automatic-var-injection'
|
||||
import { migrateBgGradient } from './migrate-bg-gradient'
|
||||
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'
|
||||
import { migrateModernizeArbitraryValues } from './migrate-modernize-arbitrary-values'
|
||||
import { migrateOptimizeModifier } from './migrate-optimize-modifier'
|
||||
import { migratePrefix } from './migrate-prefix'
|
||||
import { migrateSimpleLegacyClasses } from './migrate-simple-legacy-classes'
|
||||
import { migrateThemeToVar } from './migrate-theme-to-var'
|
||||
@ -42,8 +46,12 @@ export const DEFAULT_MIGRATIONS: Migration[] = [
|
||||
migrateVariantOrder, // Has to happen before migrations that modify variants
|
||||
migrateAutomaticVarInjection,
|
||||
migrateLegacyArbitraryValues,
|
||||
migrateArbitraryValueToBareValue,
|
||||
migrateArbitraryUtilities,
|
||||
migrateModernizeArbitraryValues,
|
||||
migrateArbitraryVariants,
|
||||
migrateDropUnnecessaryDataTypes,
|
||||
migrateArbitraryValueToBareValue,
|
||||
migrateOptimizeModifier,
|
||||
]
|
||||
|
||||
export async function migrateCandidate(
|
||||
@ -69,7 +77,7 @@ export async function migrateCandidate(
|
||||
// E.g.: `bg-red-500/[var(--my-opacity)]` -> `bg-red-500/(--my-opacity)`
|
||||
if (rawCandidate === original) {
|
||||
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
|
||||
return printCandidate(designSystem, candidate)
|
||||
return designSystem.printCandidate(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,404 @@
|
||||
import { substituteAtApply } from '../../../../tailwindcss/src/apply'
|
||||
import { atRule, styleRule, toCss, walk, type AstNode } from '../../../../tailwindcss/src/ast'
|
||||
import { printArbitraryValue } from '../../../../tailwindcss/src/candidate'
|
||||
import * as SelectorParser from '../../../../tailwindcss/src/compat/selector-parser'
|
||||
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
|
||||
import { ThemeOptions } from '../../../../tailwindcss/src/theme'
|
||||
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
|
||||
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
|
||||
import { dimensions } from '../../utils/dimension'
|
||||
|
||||
// Given a utility, compute a signature that represents the utility. The
|
||||
// signature will be a normalised form of the generated CSS for the utility, or
|
||||
// a unique symbol if the utility is not valid. The class in the selector will
|
||||
// be replaced with the `.x` selector.
|
||||
//
|
||||
// This function should only be passed the base utility so `flex`, `hover:flex`
|
||||
// and `focus:flex` will all use just `flex`. Variants are handled separately.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// | UTILITY | GENERATED SIGNATURE |
|
||||
// | ---------------- | ----------------------- |
|
||||
// | `[display:flex]` | `.x { display: flex; }` |
|
||||
// | `flex` | `.x { display: flex; }` |
|
||||
//
|
||||
// These produce the same signature, therefore they represent the same utility.
|
||||
export const computeUtilitySignature = new DefaultMap<
|
||||
DesignSystem,
|
||||
DefaultMap<string, string | Symbol>
|
||||
>((designSystem) => {
|
||||
return new DefaultMap<string, string | Symbol>((utility) => {
|
||||
try {
|
||||
// Ensure the prefix is added to the utility if it is not already present.
|
||||
utility =
|
||||
designSystem.theme.prefix && !utility.startsWith(designSystem.theme.prefix)
|
||||
? `${designSystem.theme.prefix}:${utility}`
|
||||
: utility
|
||||
|
||||
// Use `@apply` to normalize the selector to `.x`
|
||||
let ast: AstNode[] = [styleRule('.x', [atRule('@apply', utility)])]
|
||||
|
||||
temporarilyDisableThemeInline(designSystem, () => substituteAtApply(ast, designSystem))
|
||||
|
||||
// We will be mutating the AST, so we need to clone it first to not affect
|
||||
// the original AST
|
||||
ast = structuredClone(ast)
|
||||
|
||||
// Optimize the AST. This is needed such that any internal intermediate
|
||||
// nodes are gone. This will also cleanup declaration nodes with undefined
|
||||
// values or `--tw-sort` declarations.
|
||||
walk(ast, (node, { replaceWith }) => {
|
||||
// Optimize declarations
|
||||
if (node.kind === 'declaration') {
|
||||
if (node.value === undefined || node.property === '--tw-sort') {
|
||||
replaceWith([])
|
||||
}
|
||||
}
|
||||
|
||||
// Replace special nodes with its children
|
||||
else if (node.kind === 'context' || node.kind === 'at-root') {
|
||||
replaceWith(node.nodes)
|
||||
}
|
||||
|
||||
// Remove comments
|
||||
else if (node.kind === 'comment') {
|
||||
replaceWith([])
|
||||
}
|
||||
})
|
||||
|
||||
// Resolve theme values to their inlined value.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// `[color:var(--color-red-500)]` → `[color:oklch(63.7%_0.237_25.331)]`
|
||||
// `[color:oklch(63.7%_0.237_25.331)]` → `[color:oklch(63.7%_0.237_25.331)]`
|
||||
//
|
||||
// Due to the `@apply` from above, this will become:
|
||||
//
|
||||
// ```css
|
||||
// .example {
|
||||
// color: oklch(63.7% 0.237 25.331);
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// Which conveniently will be equivalent to: `text-red-500` when we inline
|
||||
// the value.
|
||||
//
|
||||
// Without inlining:
|
||||
// ```css
|
||||
// .example {
|
||||
// color: var(--color-red-500, oklch(63.7% 0.237 25.331));
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// Inlined:
|
||||
// ```css
|
||||
// .example {
|
||||
// color: oklch(63.7% 0.237 25.331);
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// Recently we made sure that utilities like `text-red-500` also generate
|
||||
// the fallback value for usage in `@reference` mode.
|
||||
//
|
||||
// The second assumption is that if you use `var(--key, fallback)` that
|
||||
// happens to match a known variable _and_ its inlined value. Then we can
|
||||
// replace it with the inlined variable. This allows us to handle custom
|
||||
// `@theme` and `@theme inline` definitions.
|
||||
walk(ast, (node) => {
|
||||
// Handle declarations
|
||||
if (node.kind === 'declaration' && node.value !== undefined) {
|
||||
if (node.value.includes('var(')) {
|
||||
let valueAst = ValueParser.parse(node.value)
|
||||
|
||||
let seen = new Set<string>()
|
||||
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
|
||||
if (valueNode.kind !== 'function') return
|
||||
if (valueNode.value !== 'var') return
|
||||
|
||||
// Resolve the underlying value of the variable
|
||||
if (valueNode.nodes.length !== 1 && valueNode.nodes.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
let variable = valueNode.nodes[0].value
|
||||
|
||||
// Drop the prefix from the variable name if it is present. The
|
||||
// internal variable doesn't have the prefix.
|
||||
if (
|
||||
designSystem.theme.prefix &&
|
||||
variable.startsWith(`--${designSystem.theme.prefix}-`)
|
||||
) {
|
||||
variable = variable.slice(`--${designSystem.theme.prefix}-`.length)
|
||||
}
|
||||
let variableValue = designSystem.resolveThemeValue(variable)
|
||||
// Prevent infinite recursion when the variable value contains the
|
||||
// variable itself.
|
||||
if (seen.has(variable)) return
|
||||
seen.add(variable)
|
||||
if (variableValue === undefined) return // Couldn't resolve the variable
|
||||
|
||||
// Inject variable fallbacks when no fallback is present yet.
|
||||
//
|
||||
// A fallback could consist of multiple values.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// ```
|
||||
// var(--font-sans, ui-sans-serif, system-ui, sans-serif, …)
|
||||
// ```
|
||||
{
|
||||
// More than 1 argument means that a fallback is already present
|
||||
if (valueNode.nodes.length === 1) {
|
||||
// Inject the fallback value into the variable lookup
|
||||
valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Replace known variable + inlined fallback value with the value
|
||||
// itself again
|
||||
{
|
||||
// We need at least 3 arguments. The variable, the separator and a fallback value.
|
||||
if (valueNode.nodes.length >= 3) {
|
||||
let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable
|
||||
let constructedValue = `${valueNode.nodes[0].value},${variableValue}`
|
||||
if (nodeAsString === constructedValue) {
|
||||
replaceWith(ValueParser.parse(variableValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Replace the value with the new value
|
||||
node.value = ValueParser.toCss(valueAst)
|
||||
}
|
||||
|
||||
// Very basic `calc(…)` constant folding to handle the spacing scale
|
||||
// multiplier:
|
||||
//
|
||||
// Input: `--spacing(4)`
|
||||
// → `calc(var(--spacing, 0.25rem) * 4)`
|
||||
// → `calc(0.25rem * 4)` ← this is the case we will see
|
||||
// after inlining the variable
|
||||
// → `1rem`
|
||||
if (node.value.includes('calc')) {
|
||||
let folded = false
|
||||
let valueAst = ValueParser.parse(node.value)
|
||||
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
|
||||
if (valueNode.kind !== 'function') return
|
||||
if (valueNode.value !== 'calc') return
|
||||
|
||||
// [
|
||||
// { kind: 'word', value: '0.25rem' }, 0
|
||||
// { kind: 'separator', value: ' ' }, 1
|
||||
// { kind: 'word', value: '*' }, 2
|
||||
// { kind: 'separator', value: ' ' }, 3
|
||||
// { kind: 'word', value: '256' } 4
|
||||
// ]
|
||||
if (valueNode.nodes.length !== 5) return
|
||||
if (valueNode.nodes[2].kind !== 'word' && valueNode.nodes[2].value !== '*') return
|
||||
|
||||
let parsed = dimensions.get(valueNode.nodes[0].value)
|
||||
if (parsed === null) return
|
||||
|
||||
let [value, unit] = parsed
|
||||
|
||||
let multiplier = Number(valueNode.nodes[4].value)
|
||||
if (Number.isNaN(multiplier)) return
|
||||
|
||||
folded = true
|
||||
replaceWith(ValueParser.parse(`${value * multiplier}${unit}`))
|
||||
})
|
||||
|
||||
if (folded) {
|
||||
node.value = ValueParser.toCss(valueAst)
|
||||
}
|
||||
}
|
||||
|
||||
// We will normalize the `node.value`, this is the same kind of logic
|
||||
// we use when printing arbitrary values. It will remove unnecessary
|
||||
// whitespace.
|
||||
//
|
||||
// Essentially normalizing the `node.value` to a canonical form.
|
||||
node.value = printArbitraryValue(node.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Compute the final signature, by generating the CSS for the utility
|
||||
let signature = toCss(ast)
|
||||
return signature
|
||||
} catch {
|
||||
// A unique symbol is returned to ensure that 2 signatures resulting in
|
||||
// `null` are not considered equal.
|
||||
return Symbol()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Given a variant, compute a signature that represents the variant. The
|
||||
// signature will be a normalised form of the generated CSS for the variant, or
|
||||
// a unique symbol if the variant is not valid. The class in the selector will
|
||||
// be replaced with `.x`.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// | VARIANT | GENERATED SIGNATURE |
|
||||
// | ---------------- | ----------------------------- |
|
||||
// | `[&:focus]:flex` | `.x:focus { display: flex; }` |
|
||||
// | `focus:flex` | `.x:focus { display: flex; }` |
|
||||
//
|
||||
// These produce the same signature, therefore they represent the same variant.
|
||||
export const computeVariantSignature = new DefaultMap<
|
||||
DesignSystem,
|
||||
DefaultMap<string, string | Symbol>
|
||||
>((designSystem) => {
|
||||
return new DefaultMap<string, string | Symbol>((variant) => {
|
||||
try {
|
||||
// Ensure the prefix is added to the utility if it is not already present.
|
||||
variant =
|
||||
designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix)
|
||||
? `${designSystem.theme.prefix}:${variant}`
|
||||
: variant
|
||||
|
||||
// Use `@apply` to normalize the selector to `.x`
|
||||
let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])]
|
||||
substituteAtApply(ast, designSystem)
|
||||
|
||||
// Canonicalize selectors to their minimal form
|
||||
walk(ast, (node) => {
|
||||
// At-rules
|
||||
if (node.kind === 'at-rule' && node.params.includes(' ')) {
|
||||
node.params = node.params.replaceAll(' ', '')
|
||||
}
|
||||
|
||||
// Style rules
|
||||
else if (node.kind === 'rule') {
|
||||
let selectorAst = SelectorParser.parse(node.selector)
|
||||
let changed = false
|
||||
SelectorParser.walk(selectorAst, (node, { replaceWith }) => {
|
||||
if (node.kind === 'separator' && node.value !== ' ') {
|
||||
node.value = node.value.trim()
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Remove unnecessary `:is(…)` selectors
|
||||
else if (node.kind === 'function' && node.value === ':is') {
|
||||
// A single selector inside of `:is(…)` can be replaced with the
|
||||
// selector itself.
|
||||
//
|
||||
// E.g.: `:is(.foo)` → `.foo`
|
||||
if (node.nodes.length === 1) {
|
||||
changed = true
|
||||
replaceWith(node.nodes)
|
||||
}
|
||||
|
||||
// A selector with the universal selector `*` followed by a pseudo
|
||||
// class, can be replaced with the pseudo class itself.
|
||||
else if (
|
||||
node.nodes.length === 2 &&
|
||||
node.nodes[0].kind === 'selector' &&
|
||||
node.nodes[0].value === '*' &&
|
||||
node.nodes[1].kind === 'selector' &&
|
||||
node.nodes[1].value[0] === ':'
|
||||
) {
|
||||
changed = true
|
||||
replaceWith(node.nodes[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure `*` exists before pseudo selectors inside of `:not(…)`,
|
||||
// `:where(…)`, …
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// `:not(:first-child)` → `:not(*:first-child)`
|
||||
//
|
||||
else if (
|
||||
node.kind === 'function' &&
|
||||
node.value[0] === ':' &&
|
||||
node.nodes[0]?.kind === 'selector' &&
|
||||
node.nodes[0]?.value[0] === ':'
|
||||
) {
|
||||
changed = true
|
||||
node.nodes.unshift({ kind: 'selector', value: '*' })
|
||||
}
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
node.selector = SelectorParser.toCss(selectorAst)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Compute the final signature, by generating the CSS for the variant
|
||||
let signature = toCss(ast)
|
||||
return signature
|
||||
} catch {
|
||||
// A unique symbol is returned to ensure that 2 signatures resulting in
|
||||
// `null` are not considered equal.
|
||||
return Symbol()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function temporarilyDisableThemeInline<T>(designSystem: DesignSystem, cb: () => T): T {
|
||||
// Turn off `@theme inline` feature such that `@theme` and `@theme inline` are
|
||||
// considered the same. The biggest motivation for this is referencing
|
||||
// variables in another namespace that happen to contain the same value as the
|
||||
// utility's own namespaces it is reading from.
|
||||
//
|
||||
// E.g.:
|
||||
//
|
||||
// The `max-w-*` utility doesn't read from the `--breakpoint-*` namespace.
|
||||
// But it does read from the `--container-*` namespace. It also happens to
|
||||
// be the case that `--breakpoint-md` and `--container-3xl` are the exact
|
||||
// same value.
|
||||
//
|
||||
// If you then use the `max-w-(--breakpoint-md)` utility, inlining the
|
||||
// variable would mean:
|
||||
// - `max-w-(--breakpoint-md)` → `max-width: 48rem;` → `max-w-3xl`
|
||||
// - `max-w-(--contianer-3xl)` → `max-width: 48rem;` → `max-w-3xl`
|
||||
//
|
||||
// Not inlining the variable would mean:
|
||||
// - `max-w-(--breakpoint-md)` → `max-width: var(--breakpoint-md);` → `max-w-(--breakpoint-md)`
|
||||
// - `max-w-(--container-3xl)` → `max-width: var(--container-3xl);` → `max-w-3xl`
|
||||
|
||||
// @ts-expect-error We are monkey-patching a method that's considered private
|
||||
// in TypeScript
|
||||
let originalGet = designSystem.theme.values.get
|
||||
|
||||
// Track all values with the inline option set, so we can restore them later.
|
||||
let restorableInlineOptions = new Set<{ options: ThemeOptions }>()
|
||||
|
||||
// @ts-expect-error We are monkey-patching a method that's considered private
|
||||
// in TypeScript
|
||||
designSystem.theme.values.get = (key: string) => {
|
||||
// @ts-expect-error We are monkey-patching a method that's considered private
|
||||
// in TypeScript
|
||||
let value = originalGet.call(designSystem.theme.values, key)
|
||||
if (value === undefined) return value
|
||||
|
||||
// Remove `inline` if it was set
|
||||
if (value.options & ThemeOptions.INLINE) {
|
||||
restorableInlineOptions.add(value)
|
||||
value.options &= ~ThemeOptions.INLINE
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
// Run the callback with the `@theme inline` feature disabled
|
||||
return cb()
|
||||
} finally {
|
||||
// Restore the `@theme inline` to the original value
|
||||
// @ts-expect-error We are monkey-patching a method that's private
|
||||
designSystem.theme.values.get = originalGet
|
||||
|
||||
// Re-add the `inline` option, in case future lookups are done
|
||||
for (let value of restorableInlineOptions) {
|
||||
value.options |= ThemeOptions.INLINE
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/@tailwindcss-upgrade/src/utils/dimension.ts
Normal file
18
packages/@tailwindcss-upgrade/src/utils/dimension.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { DefaultMap } from '../../../tailwindcss/src/utils/default-map'
|
||||
|
||||
// Parse a dimension such as `64rem` into `[64, 'rem']`.
|
||||
export const dimensions = new DefaultMap((input) => {
|
||||
let match = /^(?<value>-?(?:\d*\.)?\d+)(?<unit>[a-z]+|%)$/i.exec(input)
|
||||
if (!match) return null
|
||||
|
||||
let value = match.groups?.value
|
||||
if (value === undefined) return null
|
||||
|
||||
let unit = match.groups?.unit
|
||||
if (unit === undefined) return null
|
||||
|
||||
let valueAsNumber = Number(value)
|
||||
if (Number.isNaN(valueAsNumber)) return null
|
||||
|
||||
return [valueAsNumber, unit] as const
|
||||
})
|
||||
@ -0,0 +1,7 @@
|
||||
export function replaceObject<T extends object, U extends object | null>(target: T, source: U): U {
|
||||
// Clear out the target object, otherwise inspecting the final object will
|
||||
// look very confusing.
|
||||
for (let key in target) delete target[key]
|
||||
|
||||
return Object.assign(target, source)
|
||||
}
|
||||
1
packages/@tailwindcss-upgrade/src/utils/types.ts
Normal file
1
packages/@tailwindcss-upgrade/src/utils/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Writable<T> = T extends Readonly<infer U> ? U : T
|
||||
18
packages/@tailwindcss-upgrade/src/utils/walk-variants.ts
Normal file
18
packages/@tailwindcss-upgrade/src/utils/walk-variants.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Candidate, Variant } from '../../../tailwindcss/src/candidate'
|
||||
|
||||
export function* walkVariants(candidate: Candidate) {
|
||||
function* inner(
|
||||
variant: Variant,
|
||||
parent: Extract<Variant, { kind: 'compound' }> | null = null,
|
||||
): Iterable<[Variant, Extract<Variant, { kind: 'compound' }> | null]> {
|
||||
yield [variant, parent]
|
||||
|
||||
if (variant.kind === 'compound') {
|
||||
yield* inner(variant.variant, variant)
|
||||
}
|
||||
}
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
yield* inner(variant, null)
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,16 @@
|
||||
import type { DesignSystem } from './design-system'
|
||||
import { decodeArbitraryValue } from './utils/decode-arbitrary-value'
|
||||
import { DefaultMap } from './utils/default-map'
|
||||
import { isValidArbitrary } from './utils/is-valid-arbitrary'
|
||||
import { segment } from './utils/segment'
|
||||
import * as ValueParser from './value-parser'
|
||||
|
||||
const COLON = 0x3a
|
||||
const DASH = 0x2d
|
||||
const LOWER_A = 0x61
|
||||
const LOWER_Z = 0x7a
|
||||
|
||||
type ArbitraryUtilityValue = {
|
||||
export type ArbitraryUtilityValue = {
|
||||
kind: 'arbitrary'
|
||||
|
||||
/**
|
||||
@ -60,7 +62,7 @@ export type NamedUtilityValue = {
|
||||
fraction: string | null
|
||||
}
|
||||
|
||||
type ArbitraryModifier = {
|
||||
export type ArbitraryModifier = {
|
||||
kind: 'arbitrary'
|
||||
|
||||
/**
|
||||
@ -72,7 +74,7 @@ type ArbitraryModifier = {
|
||||
value: string
|
||||
}
|
||||
|
||||
type NamedModifier = {
|
||||
export type NamedModifier = {
|
||||
kind: 'named'
|
||||
|
||||
/**
|
||||
@ -776,3 +778,283 @@ function* findRoots(input: string, exists: (input: string) => boolean): Iterable
|
||||
yield ['@', input.slice(1)]
|
||||
}
|
||||
}
|
||||
|
||||
export function printCandidate(designSystem: DesignSystem, candidate: Candidate) {
|
||||
let parts: string[] = []
|
||||
|
||||
for (let variant of candidate.variants) {
|
||||
parts.unshift(printVariant(variant))
|
||||
}
|
||||
|
||||
// Handle prefix
|
||||
if (designSystem.theme.prefix) {
|
||||
parts.unshift(designSystem.theme.prefix)
|
||||
}
|
||||
|
||||
let base: string = ''
|
||||
|
||||
// Handle static
|
||||
if (candidate.kind === 'static') {
|
||||
base += candidate.root
|
||||
}
|
||||
|
||||
// Handle functional
|
||||
if (candidate.kind === 'functional') {
|
||||
base += candidate.root
|
||||
|
||||
if (candidate.value) {
|
||||
if (candidate.value.kind === 'arbitrary') {
|
||||
if (candidate.value !== null) {
|
||||
let isVarValue = isVar(candidate.value.value)
|
||||
let value = isVarValue ? candidate.value.value.slice(4, -1) : candidate.value.value
|
||||
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
|
||||
|
||||
if (candidate.value.dataType) {
|
||||
base += `-${open}${candidate.value.dataType}:${printArbitraryValue(value)}${close}`
|
||||
} else {
|
||||
base += `-${open}${printArbitraryValue(value)}${close}`
|
||||
}
|
||||
}
|
||||
} else if (candidate.value.kind === 'named') {
|
||||
base += `-${candidate.value.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arbitrary
|
||||
if (candidate.kind === 'arbitrary') {
|
||||
base += `[${candidate.property}:${printArbitraryValue(candidate.value)}]`
|
||||
}
|
||||
|
||||
// Handle modifier
|
||||
if (candidate.kind === 'arbitrary' || candidate.kind === 'functional') {
|
||||
base += printModifier(candidate.modifier)
|
||||
}
|
||||
|
||||
// Handle important
|
||||
if (candidate.important) {
|
||||
base += '!'
|
||||
}
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join(':')
|
||||
}
|
||||
|
||||
export function printModifier(modifier: ArbitraryModifier | NamedModifier | null) {
|
||||
if (modifier === null) return ''
|
||||
|
||||
let isVarValue = isVar(modifier.value)
|
||||
let value = isVarValue ? modifier.value.slice(4, -1) : modifier.value
|
||||
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
|
||||
|
||||
if (modifier.kind === 'arbitrary') {
|
||||
return `/${open}${printArbitraryValue(value)}${close}`
|
||||
} else if (modifier.kind === 'named') {
|
||||
return `/${modifier.value}`
|
||||
} else {
|
||||
modifier satisfies never
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function printVariant(variant: Variant) {
|
||||
// Handle static variants
|
||||
if (variant.kind === 'static') {
|
||||
return variant.root
|
||||
}
|
||||
|
||||
// Handle arbitrary variants
|
||||
if (variant.kind === 'arbitrary') {
|
||||
return `[${printArbitraryValue(simplifyArbitraryVariant(variant.selector))}]`
|
||||
}
|
||||
|
||||
let base: string = ''
|
||||
|
||||
// Handle functional variants
|
||||
if (variant.kind === 'functional') {
|
||||
base += variant.root
|
||||
// `@` is a special case for functional variants. We want to print: `@lg`
|
||||
// instead of `@-lg`
|
||||
let hasDash = variant.root !== '@'
|
||||
if (variant.value) {
|
||||
if (variant.value.kind === 'arbitrary') {
|
||||
let isVarValue = isVar(variant.value.value)
|
||||
let value = isVarValue ? variant.value.value.slice(4, -1) : variant.value.value
|
||||
let [open, close] = isVarValue ? ['(', ')'] : ['[', ']']
|
||||
|
||||
base += `${hasDash ? '-' : ''}${open}${printArbitraryValue(value)}${close}`
|
||||
} else if (variant.value.kind === 'named') {
|
||||
base += `${hasDash ? '-' : ''}${variant.value.value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compound variants
|
||||
if (variant.kind === 'compound') {
|
||||
base += variant.root
|
||||
base += '-'
|
||||
base += printVariant(variant.variant)
|
||||
}
|
||||
|
||||
// Handle modifiers
|
||||
if (variant.kind === 'functional' || variant.kind === 'compound') {
|
||||
base += printModifier(variant.modifier)
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
const printArbitraryValueCache = new DefaultMap<string, string>((input) => {
|
||||
let ast = ValueParser.parse(input)
|
||||
|
||||
let drop = new Set<ValueParser.ValueAstNode>()
|
||||
|
||||
ValueParser.walk(ast, (node, { parent }) => {
|
||||
let parentArray = parent === null ? ast : (parent.nodes ?? [])
|
||||
|
||||
// Handle operators (e.g.: inside of `calc(…)`)
|
||||
if (
|
||||
node.kind === 'word' &&
|
||||
// Operators
|
||||
(node.value === '+' || node.value === '-' || node.value === '*' || node.value === '/')
|
||||
) {
|
||||
let idx = parentArray.indexOf(node) ?? -1
|
||||
|
||||
// This should not be possible
|
||||
if (idx === -1) return
|
||||
|
||||
let previous = parentArray[idx - 1]
|
||||
if (previous?.kind !== 'separator' || previous.value !== ' ') return
|
||||
|
||||
let next = parentArray[idx + 1]
|
||||
if (next?.kind !== 'separator' || next.value !== ' ') return
|
||||
|
||||
drop.add(previous)
|
||||
drop.add(next)
|
||||
}
|
||||
|
||||
// The value parser handles `/` as a separator in some scenarios. E.g.:
|
||||
// `theme(colors.red/50%)`. Because of this, we have to handle this case
|
||||
// separately.
|
||||
else if (node.kind === 'separator' && node.value.trim() === '/') {
|
||||
node.value = '/'
|
||||
}
|
||||
|
||||
// Leading and trailing whitespace
|
||||
else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') {
|
||||
if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) {
|
||||
drop.add(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Whitespace around `,` separators can be removed.
|
||||
// E.g.: `min(1px , 2px)` -> `min(1px,2px)`
|
||||
else if (node.kind === 'separator' && node.value.trim() === ',') {
|
||||
node.value = ','
|
||||
}
|
||||
})
|
||||
|
||||
if (drop.size > 0) {
|
||||
ValueParser.walk(ast, (node, { replaceWith }) => {
|
||||
if (drop.has(node)) {
|
||||
drop.delete(node)
|
||||
replaceWith([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
recursivelyEscapeUnderscores(ast)
|
||||
|
||||
return ValueParser.toCss(ast)
|
||||
})
|
||||
export function printArbitraryValue(input: string) {
|
||||
return printArbitraryValueCache.get(input)
|
||||
}
|
||||
|
||||
const simplifyArbitraryVariantCache = new DefaultMap<string, string>((input) => {
|
||||
let ast = ValueParser.parse(input)
|
||||
|
||||
// &:is(…)
|
||||
if (
|
||||
ast.length === 3 &&
|
||||
// &
|
||||
ast[0].kind === 'word' &&
|
||||
ast[0].value === '&' &&
|
||||
// :
|
||||
ast[1].kind === 'separator' &&
|
||||
ast[1].value === ':' &&
|
||||
// is(…)
|
||||
ast[2].kind === 'function' &&
|
||||
ast[2].value === 'is'
|
||||
) {
|
||||
return ValueParser.toCss(ast[2].nodes)
|
||||
}
|
||||
|
||||
return input
|
||||
})
|
||||
function simplifyArbitraryVariant(input: string) {
|
||||
return simplifyArbitraryVariantCache.get(input)
|
||||
}
|
||||
|
||||
function recursivelyEscapeUnderscores(ast: ValueParser.ValueAstNode[]) {
|
||||
for (let node of ast) {
|
||||
switch (node.kind) {
|
||||
case 'function': {
|
||||
if (node.value === 'url' || node.value.endsWith('_url')) {
|
||||
// Don't decode underscores in url() but do decode the function name
|
||||
node.value = escapeUnderscore(node.value)
|
||||
break
|
||||
}
|
||||
|
||||
if (
|
||||
node.value === 'var' ||
|
||||
node.value.endsWith('_var') ||
|
||||
node.value === 'theme' ||
|
||||
node.value.endsWith('_theme')
|
||||
) {
|
||||
node.value = escapeUnderscore(node.value)
|
||||
for (let i = 0; i < node.nodes.length; i++) {
|
||||
recursivelyEscapeUnderscores([node.nodes[i]])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
node.value = escapeUnderscore(node.value)
|
||||
recursivelyEscapeUnderscores(node.nodes)
|
||||
break
|
||||
}
|
||||
case 'separator':
|
||||
node.value = escapeUnderscore(node.value)
|
||||
break
|
||||
case 'word': {
|
||||
// Dashed idents and variables `var(--my-var)` and `--my-var` should not
|
||||
// have underscores escaped
|
||||
if (node.value[0] !== '-' && node.value[1] !== '-') {
|
||||
node.value = escapeUnderscore(node.value)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
never(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isVarCache = new DefaultMap<string, boolean>((value) => {
|
||||
let ast = ValueParser.parse(value)
|
||||
return ast.length === 1 && ast[0].kind === 'function' && ast[0].value === 'var'
|
||||
})
|
||||
function isVar(value: string) {
|
||||
return isVarCache.get(value)
|
||||
}
|
||||
|
||||
function never(value: never): never {
|
||||
throw new Error(`Unexpected value: ${value}`)
|
||||
}
|
||||
|
||||
function escapeUnderscore(value: string): string {
|
||||
return value
|
||||
.replaceAll('_', String.raw`\_`) // Escape underscores to keep them as-is
|
||||
.replaceAll(' ', '_') // Replace spaces with underscores
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { Polyfills } from '.'
|
||||
import { optimizeAst, toCss } from './ast'
|
||||
import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate'
|
||||
import {
|
||||
parseCandidate,
|
||||
parseVariant,
|
||||
printCandidate,
|
||||
printVariant,
|
||||
type Candidate,
|
||||
type Variant,
|
||||
} from './candidate'
|
||||
import { compileAstNodes, compileCandidates } from './compile'
|
||||
import { substituteFunctions } from './css-functions'
|
||||
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
|
||||
@ -29,6 +36,9 @@ export type DesignSystem = {
|
||||
parseVariant(variant: string): Readonly<Variant> | null
|
||||
compileAstNodes(candidate: Candidate): ReturnType<typeof compileAstNodes>
|
||||
|
||||
printCandidate(candidate: Candidate): string
|
||||
printVariant(variant: Variant): string
|
||||
|
||||
getVariantOrder(): Map<Variant, number>
|
||||
resolveThemeValue(path: string, forceInline?: boolean): string | undefined
|
||||
|
||||
@ -127,6 +137,14 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
|
||||
compileAstNodes(candidate: Candidate) {
|
||||
return compiledAstNodes.get(candidate)
|
||||
},
|
||||
|
||||
printCandidate(candidate: Candidate) {
|
||||
return printCandidate(designSystem, candidate)
|
||||
},
|
||||
printVariant(variant: Variant) {
|
||||
return printVariant(variant)
|
||||
},
|
||||
|
||||
getVariantOrder() {
|
||||
let variants = Array.from(parsedVariants.values())
|
||||
variants.sort((a, z) => this.variants.compare(a, z))
|
||||
|
||||
@ -1300,6 +1300,7 @@ export function createUtilities(theme: Theme) {
|
||||
let value
|
||||
if (candidate.value.kind === 'arbitrary') {
|
||||
value = candidate.value.value
|
||||
value = negative ? `calc(${value} * -1)` : value
|
||||
return [decl('scale', value)]
|
||||
} else {
|
||||
value = theme.resolve(candidate.value.value, ['--scale'])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user