import { Features } from '..'
import { styleRule, toCss, walk, WalkAction, type AstNode } from '../ast'
import type { DesignSystem } from '../design-system'
import { segment } from '../utils/segment'
import { applyConfigToTheme } from './apply-config-to-theme'
import { applyKeyframesToTheme } from './apply-keyframes-to-theme'
import { createCompatConfig } from './config/create-compat-config'
import { resolveConfig } from './config/resolve-config'
import type { UserConfig } from './config/types'
import { registerContainerCompat } from './container'
import { darkModePlugin } from './dark-mode'
import { registerLegacyUtilities } from './legacy-utilities'
import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api'
import { registerScreensConfig } from './screens-config'
import { registerThemeVariantOverrides } from './theme-variants'
const IS_VALID_PREFIX = /^[a-z]+$/
export async function applyCompatibilityHooks({
designSystem,
base,
ast,
loadModule,
globs,
}: {
designSystem: DesignSystem
base: string
ast: AstNode[]
loadModule: (
path: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{ module: any; base: string }>
globs: { origin?: string; pattern: string }[]
}) {
let features = Features.None
let pluginPaths: [{ id: string; base: string }, CssPluginOptions | null][] = []
let configPaths: { id: string; base: string }[] = []
walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'at-rule') return
if (node.name === '@plugin') {
if (parent !== null) {
throw new Error('`@plugin` cannot be nested.')
}
let pluginPath = node.params.slice(1, -1)
if (pluginPath.length === 0) {
throw new Error('`@plugin` must have a path.')
}
let options: CssPluginOptions = {}
for (let decl of node.nodes ?? []) {
if (decl.kind !== 'declaration') {
throw new Error(
`Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`,
)
}
if (decl.value === undefined) continue
let value: CssPluginOptions[keyof CssPluginOptions] = decl.value
let parts = segment(value, ',').map((part) => {
part = part.trim()
if (part === 'null') {
return null
} else if (part === 'true') {
return true
} else if (part === 'false') {
return false
} else if (!Number.isNaN(Number(part))) {
return Number(part)
} else if (
(part[0] === '"' && part[part.length - 1] === '"') ||
(part[0] === "'" && part[part.length - 1] === "'")
) {
return part.slice(1, -1)
} else if (part[0] === '{' && part[part.length - 1] === '}') {
throw new Error(
`Unexpected \`@plugin\` option: Value of declaration \`${toCss([decl]).trim()}\` is not supported.\n\nUsing an object as a plugin option is currently only supported in JavaScript configuration files.`,
)
}
return part
})
options[decl.property] = parts.length === 1 ? parts[0] : parts
}
pluginPaths.push([
{ id: pluginPath, base: context.base },
Object.keys(options).length > 0 ? options : null,
])
replaceWith([])
features |= Features.JsPluginCompat
return
}
if (node.name === '@config') {
if (node.nodes.length > 0) {
throw new Error('`@config` cannot have a body.')
}
if (parent !== null) {
throw new Error('`@config` cannot be nested.')
}
configPaths.push({ id: node.params.slice(1, -1), base: context.base })
replaceWith([])
features |= Features.JsPluginCompat
return
}
})
registerLegacyUtilities(designSystem)
let resolveThemeVariableValue = designSystem.resolveThemeValue
designSystem.resolveThemeValue = function resolveThemeValue(path: string) {
if (path.startsWith('--')) {
return resolveThemeVariableValue(path)
}
features |= upgradeToFullPluginSupport({
designSystem,
base,
ast,
globs,
configs: [],
pluginDetails: [],
})
return designSystem.resolveThemeValue(path)
}
if (!pluginPaths.length && !configPaths.length) return Features.None
let [configs, pluginDetails] = await Promise.all([
Promise.all(
configPaths.map(async ({ id, base }) => {
let loaded = await loadModule(id, base, 'config')
return {
path: id,
base: loaded.base,
config: loaded.module as UserConfig,
}
}),
),
Promise.all(
pluginPaths.map(async ([{ id, base }, pluginOptions]) => {
let loaded = await loadModule(id, base, 'plugin')
return {
path: id,
base: loaded.base,
plugin: loaded.module as Plugin,
options: pluginOptions,
}
}),
),
])
features |= upgradeToFullPluginSupport({
designSystem,
base,
ast,
globs,
configs,
pluginDetails,
})
return features
}
function upgradeToFullPluginSupport({
designSystem,
base,
ast,
globs,
configs,
pluginDetails,
}: {
designSystem: DesignSystem
base: string
ast: AstNode[]
globs: { origin?: string; pattern: string }[]
configs: {
path: string
base: string
config: UserConfig
}[]
pluginDetails: {
path: string
base: string
plugin: Plugin
options: CssPluginOptions | null
}[]
}) {
let features = Features.None
let pluginConfigs = pluginDetails.map((detail) => {
if (!detail.options) {
return { config: { plugins: [detail.plugin] }, base: detail.base }
}
if ('__isOptionsFunction' in detail.plugin) {
return { config: { plugins: [detail.plugin(detail.options)] }, base: detail.base }
}
throw new Error(`The plugin "${detail.path}" does not accept options`)
})
let userConfig = [...pluginConfigs, ...configs]
let { resolvedConfig } = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme), base },
...userConfig,
{ config: { plugins: [darkModePlugin] }, base },
])
let { resolvedConfig: resolvedUserConfig, replacedThemeKeys } = resolveConfig(
designSystem,
userConfig,
)
let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig, {
set current(value: number) {
features |= value
},
})
for (let { handler } of resolvedConfig.plugins) {
handler(pluginApi)
}
applyConfigToTheme(designSystem, resolvedUserConfig, replacedThemeKeys)
applyKeyframesToTheme(designSystem, resolvedUserConfig, replacedThemeKeys)
registerThemeVariantOverrides(resolvedUserConfig, designSystem)
registerScreensConfig(resolvedUserConfig, designSystem)
registerContainerCompat(resolvedUserConfig, designSystem)
if (!designSystem.theme.prefix && resolvedConfig.prefix) {
if (resolvedConfig.prefix.endsWith('-')) {
resolvedConfig.prefix = resolvedConfig.prefix.slice(0, -1)
console.warn(
`The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only and is written as a variant before all utilities. We have fixed up the prefix for you. Remove the trailing \`-\` to silence this warning.`,
)
}
if (!IS_VALID_PREFIX.test(resolvedConfig.prefix)) {
throw new Error(
`The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`,
)
}
designSystem.theme.prefix = resolvedConfig.prefix
}
if (!designSystem.important && resolvedConfig.important === true) {
designSystem.important = true
}
if (typeof resolvedConfig.important === 'string') {
let wrappingSelector = resolvedConfig.important
walk(ast, (node, { replaceWith, parent }) => {
if (node.kind !== 'at-rule') return
if (node.name !== '@tailwind' || node.params !== 'utilities') return
if (parent?.kind === 'rule' && parent.selector === wrappingSelector) {
return WalkAction.Stop
}
replaceWith(styleRule(wrappingSelector, [node]))
return WalkAction.Stop
})
}
for (let candidate of resolvedConfig.blocklist) {
designSystem.invalidCandidates.add(candidate)
}
designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) {
let resolvedValue = pluginApi.theme(path, defaultValue)
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
return resolvedValue[0]
} else if (Array.isArray(resolvedValue)) {
return resolvedValue.join(', ')
} else if (typeof resolvedValue === 'string') {
return resolvedValue
}
}
for (let file of resolvedConfig.content.files) {
if ('raw' in file) {
throw new Error(
`Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`,
)
}
globs.push(file)
}
return features
}