Skip to content
Merged
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
80a90e0
hoist regex
RobinMalfait Sep 26, 2025
33dd0f8
remove async
RobinMalfait Sep 30, 2025
a62d857
tmp: annotate migrations
RobinMalfait Sep 30, 2025
c82bc30
move `printCandidate` normalization tests to core
RobinMalfait Sep 30, 2025
8c6fe35
expose `canonicalizeCandidates` on the design system
RobinMalfait Sep 26, 2025
5d6ea74
canonicalize candidates at the end of upgrading your project
RobinMalfait Sep 30, 2025
44352e8
move `migrate-bg-gradient` to core
RobinMalfait Sep 30, 2025
afa3216
move `migrate-theme-to-var` to core
RobinMalfait Sep 30, 2025
852e288
cache the converter
RobinMalfait Sep 30, 2025
4c722c0
only prefix variable that are not in `--theme(…)`
RobinMalfait Sep 30, 2025
4bf93bf
move types to core
RobinMalfait Sep 30, 2025
5de55bd
move dimensions to core
RobinMalfait Sep 30, 2025
2c9a2c7
move signatures to core
RobinMalfait Sep 30, 2025
34cb8b8
ensure `canonicalizeCandidates` returns a unique list
RobinMalfait Sep 30, 2025
0c3d48e
move `migrate-arbitrary-utilities` to core
RobinMalfait Sep 30, 2025
f088459
move `migrate-bare-utilities` to core
RobinMalfait Sep 30, 2025
ae5909c
move `migrate-deprecated-utilities` to core
RobinMalfait Sep 30, 2025
e5352d3
move `replaceObject` to core
RobinMalfait Sep 30, 2025
2863c60
move `migrate-arbitrary-variants` to core
RobinMalfait Sep 30, 2025
0eaf373
move `migrate-drop-unnecessary-data-types` to core
RobinMalfait Sep 30, 2025
683ffc5
move `migrate-arbitrary-value-to-bare-value` to core
RobinMalfait Sep 30, 2025
1c02690
move `migrate-optimize-modifier` to core
RobinMalfait Sep 30, 2025
760aef8
remove `!` from test case
RobinMalfait Oct 2, 2025
d5ba933
handle `&` and `*` in selector parser as standalone selector
RobinMalfait Oct 3, 2025
80029cd
move parts of `migrate-modernize-arbitrary-values` to core
RobinMalfait Oct 3, 2025
02ba94f
ensure we canonicalize at the end
RobinMalfait Oct 3, 2025
c956b08
big refactor
RobinMalfait Oct 3, 2025
c281426
drop unnecessary calls
RobinMalfait Oct 4, 2025
368eb80
only stringify the value when something changed
RobinMalfait Oct 4, 2025
55cbbaf
remove migration annotations
RobinMalfait Oct 4, 2025
e7ce1ad
move `selector-parser` from `./src/compat` to just `./src`
RobinMalfait Oct 5, 2025
705476c
add attribute selector parser
RobinMalfait Oct 5, 2025
226ae47
use dedicated `AttributeSelectorParser`
RobinMalfait Oct 5, 2025
4441c44
remove intellisense features from `@tailwindcss/browser`
RobinMalfait Oct 6, 2025
5b7fa87
immediately return replacement candidates
RobinMalfait Oct 6, 2025
93b97b1
remove `@property` when computing a signature
RobinMalfait Oct 6, 2025
ce08cba
add note when parsing the attribute selector as a whole
RobinMalfait Oct 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
cache the converter
We will likely clean this up later. But just trying to move things
aroudn first.
  • Loading branch information
RobinMalfait committed Oct 6, 2025
commit 852e28868f7ffa60aabcc170540d1e0cd3586324
247 changes: 125 additions & 122 deletions packages/tailwindcss/src/canonicalize-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const enum Convert {
}

function themeToVar(designSystem: DesignSystem, rawCandidate: string): string {
let convert = createConverter(designSystem)
let convert = converterCache.get(designSystem)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look at this commit, use the ?w=1 GitHub flag. The only real changes here are:

  1. Re-indent because of the wrapping DefaultMap
  2. The removal of the prettyPrint flag, that was only used in the CSS part (which still exists in the CSS version where whitespace is fine.


for (let candidate of parseCandidate(rawCandidate, designSystem)) {
let clone = structuredClone(candidate)
Expand Down Expand Up @@ -122,161 +122,164 @@ function themeToVar(designSystem: DesignSystem, rawCandidate: string): string {
return rawCandidate
}

function createConverter(designSystem: DesignSystem, { prettyPrint = false } = {}) {
function convert(input: string, options = Convert.All): [string, CandidateModifier | null] {
let ast = ValueParser.parse(input)
const converterCache = new DefaultMap((ds: DesignSystem) => {
return createConverter(ds)

// In some scenarios (e.g.: variants), we can't migrate to `var(…)` if it
// ends up in the `@media (…)` part. In this case we only have to migrate to
// the new `theme(…)` notation.
if (options & Convert.MigrateThemeOnly) {
return [substituteFunctionsInValue(ast, toTheme), null]
}
function createConverter(designSystem: DesignSystem) {
function convert(input: string, options = Convert.All): [string, CandidateModifier | null] {
let ast = ValueParser.parse(input)

let themeUsageCount = 0
let themeModifierCount = 0
// In some scenarios (e.g.: variants), we can't migrate to `var(…)` if it
// ends up in the `@media (…)` part. In this case we only have to migrate to
// the new `theme(…)` notation.
if (options & Convert.MigrateThemeOnly) {
return [substituteFunctionsInValue(ast, toTheme), null]
}

// Analyze AST
ValueParser.walk(ast, (node) => {
if (node.kind !== 'function') return
if (node.value !== 'theme') return
let themeUsageCount = 0
let themeModifierCount = 0

// We are only interested in the `theme` function
themeUsageCount += 1
// Analyze AST
ValueParser.walk(ast, (node) => {
if (node.kind !== 'function') return
if (node.value !== 'theme') return

// Figure out if a modifier is used
ValueParser.walk(node.nodes, (child) => {
// If we see a `,`, it means that we have a fallback value
if (child.kind === 'separator' && child.value.includes(',')) {
return ValueParser.ValueWalkAction.Stop
}

// If we see a `/`, we have a modifier
else if (child.kind === 'separator' && child.value.trim() === '/') {
themeModifierCount += 1
return ValueParser.ValueWalkAction.Stop
}
// We are only interested in the `theme` function
themeUsageCount += 1

return ValueParser.ValueWalkAction.Skip
})
})

// No `theme(…)` calls, nothing to do
if (themeUsageCount === 0) {
return [input, null]
}
// Figure out if a modifier is used
ValueParser.walk(node.nodes, (child) => {
// If we see a `,`, it means that we have a fallback value
if (child.kind === 'separator' && child.value.includes(',')) {
return ValueParser.ValueWalkAction.Stop
}

// No `theme(…)` with modifiers, we can migrate to `var(…)`
if (themeModifierCount === 0) {
return [substituteFunctionsInValue(ast, toVar), null]
}
// If we see a `/`, we have a modifier
else if (child.kind === 'separator' && child.value.trim() === '/') {
themeModifierCount += 1
return ValueParser.ValueWalkAction.Stop
}

// Multiple modifiers which means that there are multiple `theme(…/…)`
// values. In this case, we can't convert the modifier to a candidate
// modifier.
//
// We also can't migrate to `var(…)` because that would lose the modifier.
//
// Try to convert each `theme(…)` call to the modern syntax.
if (themeModifierCount > 1) {
return [substituteFunctionsInValue(ast, toTheme), null]
}
return ValueParser.ValueWalkAction.Skip
})
})

// Only a single `theme(…)` with a modifier left, that modifier will be
// migrated to a candidate modifier.
let modifier: CandidateModifier | null = null
let result = substituteFunctionsInValue(ast, (path, fallback) => {
let parts = segment(path, '/').map((part) => part.trim())
// No `theme(…)` calls, nothing to do
if (themeUsageCount === 0) {
return [input, null]
}

// Multiple `/` separators, which makes this an invalid path
if (parts.length > 2) return null
// No `theme(…)` with modifiers, we can migrate to `var(…)`
if (themeModifierCount === 0) {
return [substituteFunctionsInValue(ast, toVar), null]
}

// The path contains a `/`, which means that there is a modifier such as
// `theme(colors.red.500/50%)`.
// Multiple modifiers which means that there are multiple `theme(…/…)`
// values. In this case, we can't convert the modifier to a candidate
// modifier.
//
// Currently, we are assuming that this is only being used for colors,
// which means that we can typically convert them to a modifier on the
// candidate itself.
// We also can't migrate to `var(…)` because that would lose the modifier.
//
// If there is more than one node in the AST though, `theme(…)` must not
// be the whole value so it's not safe to use a modifier instead.
//
// E.g.: `inset 0px 1px theme(colors.red.500/50%)` is a shadow, not a color.
if (ast.length === 1 && parts.length === 2 && options & Convert.MigrateModifier) {
let [pathPart, modifierPart] = parts
// Try to convert each `theme(…)` call to the modern syntax.
if (themeModifierCount > 1) {
return [substituteFunctionsInValue(ast, toTheme), null]
}

// 50% -> /50
if (/^\d+%$/.test(modifierPart)) {
modifier = { kind: 'named', value: modifierPart.slice(0, -1) }
}
// Only a single `theme(…)` with a modifier left, that modifier will be
// migrated to a candidate modifier.
let modifier: CandidateModifier | null = null
let result = substituteFunctionsInValue(ast, (path, fallback) => {
let parts = segment(path, '/').map((part) => part.trim())

// Multiple `/` separators, which makes this an invalid path
if (parts.length > 2) return null

// The path contains a `/`, which means that there is a modifier such as
// `theme(colors.red.500/50%)`.
//
// Currently, we are assuming that this is only being used for colors,
// which means that we can typically convert them to a modifier on the
// candidate itself.
//
// If there is more than one node in the AST though, `theme(…)` must not
// be the whole value so it's not safe to use a modifier instead.
//
// E.g.: `inset 0px 1px theme(colors.red.500/50%)` is a shadow, not a color.
if (ast.length === 1 && parts.length === 2 && options & Convert.MigrateModifier) {
let [pathPart, modifierPart] = parts

// 50% -> /50
if (/^\d+%$/.test(modifierPart)) {
modifier = { kind: 'named', value: modifierPart.slice(0, -1) }
}

// .12 -> /12
// .12345 -> /[12.345]
else if (/^0?\.\d+$/.test(modifierPart)) {
let value = Number(modifierPart) * 100
modifier = {
kind: Number.isInteger(value) ? 'named' : 'arbitrary',
value: value.toString(),
// .12 -> /12
// .12345 -> /[12.345]
else if (/^0?\.\d+$/.test(modifierPart)) {
let value = Number(modifierPart) * 100
modifier = {
kind: Number.isInteger(value) ? 'named' : 'arbitrary',
value: value.toString(),
}
}

// Anything else becomes arbitrary
else {
modifier = { kind: 'arbitrary', value: modifierPart }
}
}

// Anything else becomes arbitrary
else {
modifier = { kind: 'arbitrary', value: modifierPart }
// Update path to be the first part
path = pathPart
}

// Update path to be the first part
path = pathPart
}
return toVar(path, fallback) || toTheme(path, fallback)
})

return toVar(path, fallback) || toTheme(path, fallback)
})
return [result, modifier]
}

return [result, modifier]
}
function pathToVariableName(path: string) {
let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const
if (!designSystem.theme.get([variable])) return null

function pathToVariableName(path: string) {
let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const
if (!designSystem.theme.get([variable])) return null
if (designSystem.theme.prefix) {
return `--${designSystem.theme.prefix}-${variable.slice(2)}`
}

if (designSystem.theme.prefix) {
return `--${designSystem.theme.prefix}-${variable.slice(2)}`
return variable
}

return variable
}
function toVar(path: string, fallback?: string) {
let variable = pathToVariableName(path)
if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${variable})`

function toVar(path: string, fallback?: string) {
let variable = pathToVariableName(path)
if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${variable})`
let keyPath = toKeyPath(path)
if (keyPath[0] === 'spacing' && designSystem.theme.get(['--spacing'])) {
let multiplier = keyPath[1]
if (!isValidSpacingMultiplier(multiplier)) return null

let keyPath = toKeyPath(path)
if (keyPath[0] === 'spacing' && designSystem.theme.get(['--spacing'])) {
let multiplier = keyPath[1]
if (!isValidSpacingMultiplier(multiplier)) return null
return `--spacing(${multiplier})`
}

return `--spacing(${multiplier})`
return null
}

return null
}
function toTheme(path: string, fallback?: string) {
let parts = segment(path, '/').map((part) => part.trim())
path = parts.shift()!

function toTheme(path: string, fallback?: string) {
let parts = segment(path, '/').map((part) => part.trim())
path = parts.shift()!
let variable = pathToVariableName(path)
if (!variable) return null

let variable = pathToVariableName(path)
if (!variable) return null
let modifier = parts.length > 0 ? `/${parts.join('/')}` : ''
return fallback
? `--theme(${variable}${modifier}, ${fallback})`
: `--theme(${variable}${modifier})`
}

let modifier =
parts.length > 0 ? (prettyPrint ? ` / ${parts.join(' / ')}` : `/${parts.join('/')}`) : ''
return fallback
? `--theme(${variable}${modifier}, ${fallback})`
: `--theme(${variable}${modifier})`
return convert
}

return convert
}
})

function substituteFunctionsInValue(
ast: ValueParser.ValueAstNode[],
Expand Down