import { version } from '../package.json'
import { substituteAtApply } from './apply'
import { comment, decl, rule, toCss, walk, WalkAction, type Rule } from './ast'
import type { UserConfig } from './compat/config/types'
import { compileCandidates } from './compile'
import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './css-functions'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { registerPlugins, type CssPluginOptions, type Plugin } from './plugin-api'
import { Theme } from './theme'
import { segment } from './utils/segment'
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
type CompileOptions = {
loadPlugin?: (path: string) => Promise<Plugin>
loadConfig?: (path: string) => Promise<UserConfig>
}
function throwOnPlugin(): never {
throw new Error('No `loadPlugin` function provided to `compile`')
}
function throwOnConfig(): never {
throw new Error('No `loadConfig` function provided to `compile`')
}
function parseThemeOptions(selector: string) {
let isReference = false
let isInline = false
let isDefault = false
for (let option of segment(selector.slice(6) , ' ')) {
if (option === 'reference') {
isReference = true
} else if (option === 'inline') {
isInline = true
} else if (option === 'default') {
isDefault = true
}
}
return { isReference, isInline, isDefault }
}
async function parseCss(
css: string,
{ loadPlugin = throwOnPlugin, loadConfig = throwOnConfig }: CompileOptions = {},
) {
let ast = CSS.parse(css)
let theme = new Theme()
let pluginPaths: [string, CssPluginOptions | null][] = []
let configPaths: string[] = []
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule: Rule | null = null
let keyframesRules: Rule[] = []
let globs: { origin?: string; pattern: string }[] = []
walk(ast, (node, { parent, replaceWith }) => {
if (node.kind !== 'rule') return
if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) {
if (parent !== null) {
throw new Error('`@plugin` cannot be nested.')
}
let pluginPath = node.selector.slice(9, -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([pluginPath, Object.keys(options).length > 0 ? options : null])
replaceWith([])
return
}
if (node.selector === '@config' || node.selector.startsWith('@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(node.selector.slice(9, -1))
replaceWith([])
return
}
if (node.selector.startsWith('@utility ')) {
let name = node.selector.slice(9).trim()
if (!IS_VALID_UTILITY_NAME.test(name)) {
throw new Error(
`\`@utility ${name}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`,
)
}
if (node.nodes.length === 0) {
throw new Error(
`\`@utility ${name}\` is empty. Utilities should include at least one property.`,
)
}
customUtilities.push((designSystem) => {
designSystem.utilities.static(name, (candidate) => {
if (candidate.negative) return
return structuredClone(node.nodes)
})
})
return
}
if (node.selector.startsWith('@source ')) {
if (node.nodes.length > 0) {
throw new Error('`@source` cannot have a body.')
}
if (parent !== null) {
throw new Error('`@source` cannot be nested.')
}
let path = node.selector.slice(8)
if (
(path[0] === '"' && path[path.length - 1] !== '"') ||
(path[0] === "'" && path[path.length - 1] !== "'") ||
(path[0] !== "'" && path[0] !== '"')
) {
throw new Error('`@source` paths must be quoted.')
}
globs.push({ pattern: path.slice(1, -1) })
replaceWith([])
return
}
if (node.selector.startsWith('@variant ')) {
if (parent !== null) {
throw new Error('`@variant` cannot be nested.')
}
replaceWith([])
let [name, selector] = segment(node.selector.slice(9), ' ')
if (node.nodes.length > 0 && selector) {
throw new Error(`\`@variant ${name}\` cannot have both a selector and a body.`)
}
if (node.nodes.length === 0) {
if (!selector) {
throw new Error(`\`@variant ${name}\` has no selector or body.`)
}
let selectors = segment(selector.slice(1, -1), ',')
customVariants.push((designSystem) => {
designSystem.variants.static(name, (r) => {
r.nodes = selectors.map((selector) => rule(selector, r.nodes))
})
})
return
}
else {
customVariants.push((designSystem) => {
designSystem.variants.fromAst(name, node.nodes)
})
return
}
}
if (node.selector.startsWith('@media theme(')) {
let themeParams = node.selector.slice(13, -1)
walk(node.nodes, (child) => {
if (child.kind !== 'rule') {
throw new Error(
'Files imported with `@import "…" theme(…)` must only contain `@theme` blocks.',
)
}
if (child.selector === '@theme' || child.selector.startsWith('@theme ')) {
child.selector += ' ' + themeParams
return WalkAction.Skip
}
})
replaceWith(node.nodes)
return WalkAction.Skip
}
if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return
let { isReference, isInline, isDefault } = parseThemeOptions(node.selector)
walk(node.nodes, (child, { replaceWith }) => {
if (child.kind === 'rule' && child.selector.startsWith('@keyframes ')) {
keyframesRules.push(child)
replaceWith([])
return WalkAction.Skip
}
if (child.kind === 'comment') return
if (child.kind === 'declaration' && child.property.startsWith('--')) {
theme.add(child.property, child.value ?? '', { isReference, isInline, isDefault })
return
}
let snippet = toCss([rule(node.selector, [child])])
.split('\n')
.map((line, idx, all) => `${idx === 0 || idx >= all.length - 2 ? ' ' : '>'} ${line}`)
.join('\n')
throw new Error(
`\`@theme\` blocks must only contain custom properties or \`@keyframes\`.\n\n${snippet}`,
)
})
if (!firstThemeRule && !isReference) {
firstThemeRule = node
} else {
replaceWith([])
}
return WalkAction.Skip
})
let designSystem = buildDesignSystem(theme)
let configs = await Promise.all(
configPaths.map(async (configPath) => ({
path: configPath,
config: await loadConfig(configPath),
})),
)
let plugins = await Promise.all(
pluginPaths.map(async ([pluginPath, pluginOptions]) => ({
path: pluginPath,
plugin: await loadPlugin(pluginPath),
options: pluginOptions,
})),
)
let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs)
for (let customVariant of customVariants) {
customVariant(designSystem)
}
for (let customUtility of customUtilities) {
customUtility(designSystem)
}
if (firstThemeRule) {
firstThemeRule = firstThemeRule as Rule
firstThemeRule.selector = ':root'
let nodes = []
for (let [key, value] of theme.entries()) {
if (value.isReference) continue
nodes.push(decl(key, value.value))
}
if (keyframesRules.length > 0) {
let animationParts = [...theme.namespace('--animate').values()].flatMap((animation) =>
animation.split(' '),
)
for (let keyframesRule of keyframesRules) {
let keyframesName = keyframesRule.selector.slice(11)
if (!animationParts.includes(keyframesName)) {
continue
}
nodes.push(
Object.assign(keyframesRule, {
selector: '@at-root',
nodes: [rule(keyframesRule.selector, keyframesRule.nodes)],
}),
)
}
}
firstThemeRule.nodes = nodes
}
if (css.includes('@apply')) {
substituteAtApply(ast, designSystem)
}
if (plugins.length > 0 || configs.length > 0 || css.includes(THEME_FUNCTION_INVOCATION)) {
substituteFunctions(ast, pluginApi)
}
walk(ast, (node, { replaceWith }) => {
if (node.kind !== 'rule') return
if (node.selector[0] === '@' && node.selector.startsWith('@utility ')) {
replaceWith([])
}
return WalkAction.Skip
})
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({ origin: file.base, pattern: file.pattern })
}
return {
designSystem,
pluginApi,
ast,
globs,
}
}
export async function compile(
css: string,
opts: CompileOptions = {},
): Promise<{
globs: { origin?: string; pattern: string }[]
build(candidates: string[]): string
}> {
let { designSystem, ast, globs, pluginApi } = await parseCss(css, opts)
let tailwindUtilitiesNode: Rule | null = null
walk(ast, (node) => {
if (node.kind === 'rule' && node.selector === '@tailwind utilities') {
tailwindUtilitiesNode = node
return WalkAction.Stop
}
})
if (process.env.NODE_ENV !== 'test') {
ast.unshift(comment(`! tailwindcss v${getVersion()} | MIT License | https://tailwindcss.com `))
}
let invalidCandidates = new Set<string>()
function onInvalidCandidate(candidate: string) {
invalidCandidates.add(candidate)
}
let allValidCandidates = new Set<string>()
let compiledCss = toCss(ast)
let previousAstNodeCount = 0
return {
globs,
build(newRawCandidates: string[]) {
let didChange = false
let prevSize = allValidCandidates.size
for (let candidate of newRawCandidates) {
if (!invalidCandidates.has(candidate)) {
allValidCandidates.add(candidate)
didChange ||= allValidCandidates.size !== prevSize
}
}
if (!didChange) {
return compiledCss
}
if (tailwindUtilitiesNode) {
let newNodes = compileCandidates(allValidCandidates, designSystem, {
onInvalidCandidate,
}).astNodes
if (previousAstNodeCount === newNodes.length) {
return compiledCss
}
substituteFunctions(newNodes, pluginApi)
previousAstNodeCount = newNodes.length
tailwindUtilitiesNode.nodes = newNodes
compiledCss = toCss(ast)
}
return compiledCss
},
}
}
export async function __unstable__loadDesignSystem(css: string, opts: CompileOptions = {}) {
let result = await parseCss(css, opts)
return result.designSystem
}
function getVersion() {
if (process.env.VERSION) {
return process.env.VERSION
} else {
return version
}
}