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'
import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type'
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 { walkVariants } from '../../utils/walk-variants'
export const enum Convert {
All = 0,
MigrateModifier = 1 << 0,
MigrateThemeOnly = 1 << 1,
}
export function migrateThemeToVar(
designSystem: DesignSystem,
_userConfig: Config | null,
rawCandidate: string,
): string {
let convert = createConverter(designSystem)
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
let clone = structuredClone(candidate)
let changed = false
if (clone.kind === 'arbitrary') {
let [newValue, modifier] = convert(
clone.value,
clone.modifier === null ? Convert.MigrateModifier : Convert.All,
)
if (newValue !== clone.value) {
changed = true
clone.value = newValue
if (modifier !== null) {
clone.modifier = modifier
}
}
} else if (clone.kind === 'functional' && clone.value?.kind === 'arbitrary') {
let [newValue, modifier] = convert(
clone.value.value,
clone.modifier === null ? Convert.MigrateModifier : Convert.All,
)
if (newValue !== clone.value.value) {
changed = true
clone.value.value = newValue
if (modifier !== null) {
clone.modifier = modifier
}
}
}
for (let [variant] of walkVariants(clone)) {
if (variant.kind === 'arbitrary') {
let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly)
if (newValue !== variant.selector) {
changed = true
variant.selector = newValue
}
} else if (variant.kind === 'functional' && variant.value?.kind === 'arbitrary') {
let [newValue] = convert(variant.value.value, Convert.MigrateThemeOnly)
if (newValue !== variant.value.value) {
changed = true
variant.value.value = newValue
}
}
}
return changed ? designSystem.printCandidate(clone) : rawCandidate
}
return rawCandidate
}
export function createConverter(designSystem: DesignSystem, { prettyPrint = false } = {}) {
function convert(input: string, options = Convert.All): [string, CandidateModifier | null] {
let ast = ValueParser.parse(input)
if (options & Convert.MigrateThemeOnly) {
return [substituteFunctionsInValue(ast, toTheme), null]
}
let themeUsageCount = 0
let themeModifierCount = 0
ValueParser.walk(ast, (node) => {
if (node.kind !== 'function') return
if (node.value !== 'theme') return
themeUsageCount += 1
ValueParser.walk(node.nodes, (child) => {
if (child.kind === 'separator' && child.value.includes(',')) {
return ValueParser.ValueWalkAction.Stop
}
else if (child.kind === 'separator' && child.value.trim() === '/') {
themeModifierCount += 1
return ValueParser.ValueWalkAction.Stop
}
return ValueParser.ValueWalkAction.Skip
})
})
if (themeUsageCount === 0) {
return [input, null]
}
if (themeModifierCount === 0) {
return [substituteFunctionsInValue(ast, toVar), null]
}
if (themeModifierCount > 1) {
return [substituteFunctionsInValue(ast, toTheme), null]
}
let modifier: CandidateModifier | null = null
let result = substituteFunctionsInValue(ast, (path, fallback) => {
let parts = segment(path, '/').map((part) => part.trim())
if (parts.length > 2) return null
if (ast.length === 1 && parts.length === 2 && options & Convert.MigrateModifier) {
let [pathPart, modifierPart] = parts
if (/^\d+%$/.test(modifierPart)) {
modifier = { kind: 'named', value: modifierPart.slice(0, -1) }
}
else if (/^0?\.\d+$/.test(modifierPart)) {
let value = Number(modifierPart) * 100
modifier = {
kind: Number.isInteger(value) ? 'named' : 'arbitrary',
value: value.toString(),
}
}
else {
modifier = { kind: 'arbitrary', value: modifierPart }
}
path = pathPart
}
return toVar(path, fallback) || toTheme(path, fallback)
})
return [result, modifier]
}
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)}`
}
return 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
return `--spacing(${multiplier})`
}
return null
}
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 modifier =
parts.length > 0 ? (prettyPrint ? ` / ${parts.join(' / ')}` : `/${parts.join('/')}`) : ''
return fallback
? `--theme(${variable}${modifier}, ${fallback})`
: `--theme(${variable}${modifier})`
}
return convert
}
function substituteFunctionsInValue(
ast: ValueParser.ValueAstNode[],
handle: (value: string, fallback?: string) => string | null,
) {
ValueParser.walk(ast, (node, { parent, replaceWith }) => {
if (node.kind === 'function' && node.value === 'theme') {
if (node.nodes.length < 1) return
if (node.nodes[0].kind === 'separator' && node.nodes[0].value.trim() === '') {
node.nodes.shift()
}
let pathNode = node.nodes[0]
if (pathNode.kind !== 'word') return
let path = pathNode.value
let skipUntilIndex = 1
for (let i = skipUntilIndex; i < node.nodes.length; i++) {
if (node.nodes[i].value.includes(',')) {
break
}
path += ValueParser.toCss([node.nodes[i]])
skipUntilIndex = i + 1
}
path = eventuallyUnquote(path)
let fallbackValues = node.nodes.slice(skipUntilIndex + 1)
let replacement =
fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path)
if (replacement === null) return
if (parent) {
let idx = parent.nodes.indexOf(node) - 1
while (idx !== -1) {
let previous = parent.nodes[idx]
if (previous.kind === 'separator' && previous.value.trim() === '') {
idx -= 1
continue
}
if (/^[-+*/]$/.test(previous.value.trim())) {
replacement = `(${replacement})`
}
break
}
}
replaceWith(ValueParser.parse(replacement))
}
})
return ValueParser.toCss(ast)
}
function eventuallyUnquote(value: string) {
if (value[0] !== "'" && value[0] !== '"') return value
let unquoted = ''
let quoteChar = value[0]
for (let i = 1; i < value.length - 1; i++) {
let currentChar = value[i]
let nextChar = value[i + 1]
if (currentChar === '\\' && (nextChar === quoteChar || nextChar === '\\')) {
unquoted += nextChar
i++
} else {
unquoted += currentChar
}
}
return unquoted
}