import { Features } from '..'
import { styleRule, toCss, walk, WalkAction, type AstNode } from '../ast'
import type { DesignSystem } from '../design-system'
import type { SourceLocation } from '../source-maps/source'
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,
sources,
}: {
designSystem: DesignSystem
base: string
ast: AstNode[]
loadModule: (
path: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{
path: string
base: string
module: any
}>
sources: { base: string; pattern: string; negated: boolean }[]
}) {
let features = Features.None
let pluginPaths: [
{ id: string; base: string; reference: boolean; src: SourceLocation | undefined },
CssPluginOptions | null,
][] = []
let configPaths: {
id: string
base: string
reference: boolean
src: SourceLocation | undefined
}[] = []
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 as string,
reference: !!context.reference,
src: node.src,
},
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 as string,
reference: !!context.reference,
src: node.src,
})
replaceWith([])
features |= Features.JsPluginCompat
return
}
})
registerLegacyUtilities(designSystem)
let resolveThemeVariableValue = designSystem.resolveThemeValue
designSystem.resolveThemeValue = function resolveThemeValue(path: string, forceInline?: boolean) {
if (path.startsWith('--')) {
return resolveThemeVariableValue(path, forceInline)
}
features |= upgradeToFullPluginSupport({
designSystem,
base,
ast,
sources,
configs: [],
pluginDetails: [],
})
return designSystem.resolveThemeValue(path, forceInline)
}
if (!pluginPaths.length && !configPaths.length) return Features.None
let [configs, pluginDetails] = await Promise.all([
Promise.all(
configPaths.map(async ({ id, base, reference, src }) => {
let loaded = await loadModule(id, base, 'config')
return {
path: id,
base: loaded.base,
config: loaded.module as UserConfig,
reference,
src,
}
}),
),
Promise.all(
pluginPaths.map(async ([{ id, base, reference, src }, pluginOptions]) => {
let loaded = await loadModule(id, base, 'plugin')
return {
path: id,
base: loaded.base,
plugin: loaded.module as Plugin,
options: pluginOptions,
reference,
src,
}
}),
),
])
features |= upgradeToFullPluginSupport({
designSystem,
base,
ast,
sources,
configs,
pluginDetails,
})
return features
}
function upgradeToFullPluginSupport({
designSystem,
base,
ast,
sources,
configs,
pluginDetails,
}: {
designSystem: DesignSystem
base: string
ast: AstNode[]
sources: { base: string; pattern: string; negated: boolean }[]
configs: {
path: string
base: string
config: UserConfig
reference: boolean
src: SourceLocation | undefined
}[]
pluginDetails: {
path: string
base: string
plugin: Plugin
options: CssPluginOptions | null
reference: boolean
src: SourceLocation | undefined
}[]
}) {
let features = Features.None
let pluginConfigs = pluginDetails.map((detail) => {
if (!detail.options) {
return {
config: { plugins: [detail.plugin] },
base: detail.base,
reference: detail.reference,
src: detail.src,
}
}
if ('__isOptionsFunction' in detail.plugin) {
return {
config: { plugins: [detail.plugin(detail.options)] },
base: detail.base,
reference: detail.reference,
src: detail.src,
}
}
throw new Error(`The plugin "${detail.path}" does not accept options`)
})
let userConfig = [...pluginConfigs, ...configs]
let { resolvedConfig } = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme), base, reference: true, src: undefined },
...userConfig,
{ config: { plugins: [darkModePlugin] }, base, reference: true, src: undefined },
])
let { resolvedConfig: resolvedUserConfig, replacedThemeKeys } = resolveConfig(
designSystem,
userConfig,
)
let pluginApiConfig = {
designSystem,
ast,
resolvedConfig,
featuresRef: {
set current(value: number) {
features |= value
},
},
}
let sharedPluginApi = buildPluginApi({
...pluginApiConfig,
referenceMode: false,
src: undefined,
})
let defaultResolveThemeValue = designSystem.resolveThemeValue
designSystem.resolveThemeValue = function resolveThemeValue(path: string, forceInline?: boolean) {
if (path[0] === '-' && path[1] === '-') {
return defaultResolveThemeValue(path, forceInline)
}
let resolvedValue = sharedPluginApi.theme(path, undefined)
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 { handler, reference, src } of resolvedConfig.plugins) {
let api = buildPluginApi({
...pluginApiConfig,
referenceMode: reference ?? false,
src,
})
handler(api)
}
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)
}
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.`,
)
}
let negated = false
if (file.pattern[0] == '!') {
negated = true
file.pattern = file.pattern.slice(1)
}
sources.push({ ...file, negated })
}
return features
}