diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index b5e89ad4f..b40abd4e5 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -195,3 +195,37 @@ test('Config files can affect the theme', async ({ expect }) => { " `) }) + +test('Variants in CSS overwrite variants from plugins', async ({ expect }) => { + let input = css` + @tailwind utilities; + @config "./config.js"; + @variant dark (&:is(.my-dark)); + @variant light (&:is(.my-light)); + ` + + let compiler = await compile(input, { + loadConfig: async () => ({ + darkMode: ['variant', '&:is(.dark)'], + plugins: [ + plugin(function ({ addVariant }) { + addVariant('light', '&:is(.light)') + }), + ], + }), + }) + + expect(compiler.build(['dark:underline', 'light:underline'])).toMatchInlineSnapshot(` + ".dark\\:underline { + &:is(.my-dark) { + text-decoration-line: underline; + } + } + .light\\:underline { + &:is(.my-light) { + text-decoration-line: underline; + } + } + " + `) +}) diff --git a/packages/tailwindcss/src/compat/merge-into-theme.test.ts b/packages/tailwindcss/src/compat/merge-into-theme.test.ts new file mode 100644 index 000000000..f5e1606d5 --- /dev/null +++ b/packages/tailwindcss/src/compat/merge-into-theme.test.ts @@ -0,0 +1,47 @@ +import { test } from 'vitest' +import { Theme } from '../theme' +import { mergeIntoTheme } from './merge-into-theme' + +test('Config values can be merged into the theme', ({ expect }) => { + let theme = new Theme() + + mergeIntoTheme(theme, { + colors: { + primary: '#c0ffee', + red: { + 500: 'red', + }, + }, + + fontSize: { + sm: '0.875rem', + base: [ + '1rem', + { + lineHeight: '1.5', + }, + ], + }, + }) + + expect(theme.resolve('primary', ['--colors'] as any[])).toEqual('#c0ffee') + expect(theme.resolve('red-500', ['--colors'] as any[])).toEqual('red') + expect(theme.resolve('sm', ['--font-size'] as any[])).toEqual('0.875rem') + expect(theme.resolve('base', ['--font-size'] as any[])).toEqual('1rem') + expect(theme.get(['--font-size-base--line-height'] as any[])).toEqual('1.5') +}) + +test('Existing values are not overwritten', ({ expect }) => { + let theme = new Theme() + theme.add('--colors-primary', '#c0ffee') + + mergeIntoTheme(theme, { + colors: { + primary: '#c0ffee', + secondary: '#bada55', + }, + }) + + expect(theme.resolve('primary', ['--colors'] as any[])).toEqual('var(--colors-primary, #c0ffee)') + expect(theme.resolve('secondary', ['--colors'] as any[])).toEqual('#bada55') +}) diff --git a/packages/tailwindcss/src/compat/merge-into-theme.ts b/packages/tailwindcss/src/compat/merge-into-theme.ts new file mode 100644 index 000000000..8929db6bf --- /dev/null +++ b/packages/tailwindcss/src/compat/merge-into-theme.ts @@ -0,0 +1,125 @@ +import type { DesignSystem } from '../design-system' +import { resolveConfig, type ConfigFile } from './config/resolve-config' +import type { ResolvedConfig } from './config/types' + +export function mergeIntoTheme(designSystem: DesignSystem, configs: ConfigFile[]) { + let theme = resolveConfig(designSystem, configs).theme + + for (let [path, value] of themeableValues(theme)) { + let name = keyPathToCssProperty(path) + + designSystem.theme.add(`--${name}`, value as any, { + isInline: true, + isReference: false, + }) + } + + return theme +} + +function themeableValues(config: ResolvedConfig['theme']): [string[], unknown][] { + let toAdd: [string[], unknown][] = [] + + walk(config as any, [], (value, path) => { + if (isValidThemePrimitive(value)) { + toAdd.push([path, value]) + + return WalkAction.Skip + } + + if (isValidThemeTuple(value)) { + toAdd.push([path, value[0]]) + + for (let key of Reflect.ownKeys(value[1]) as string[]) { + toAdd.push([[...path, `-${key}`], value[1][key]]) + } + + return WalkAction.Skip + } + }) + + return toAdd +} + +function keyPathToCssProperty(path: string[]) { + return ( + path + // [1] should move into the nested object tuple. To create the CSS variable + // name for this, we replace it with an empty string that will result in two + // subsequent dashes when joined. + .map((path) => (path === '1' ? '' : path)) + + // Resolve the key path to a CSS variable segment + .map((part) => + part + .replaceAll('.', '_') + .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), + ) + + // Remove the `DEFAULT` key at the end of a path + // We're reading from CSS anyway so it'll be a string + .filter((part, index) => part !== 'DEFAULT' || index !== path.length - 1) + .join('-') + ) +} + +function isValidThemePrimitive(value: unknown) { + return typeof value === 'number' || typeof value === 'string' +} + +function isValidThemeTuple(value: unknown): value is [string, Record] { + // Check for tuple values of the form + // `[string, Record]` + if (!Array.isArray(value)) return false + if (value.length !== 2) return false + + // A string or number as the "value" + if (typeof value[0] !== 'string' && typeof value[0] !== 'number') return false + + // An object as the nested theme values + if (value[1] === undefined || value[1] === null) return false + if (typeof value[1] !== 'object') return false + + for (let key of Reflect.ownKeys(value[1])) { + if (typeof key !== 'string') return false + if (typeof value[1][key] !== 'string' && typeof value[1][key] !== 'number') return false + } + + return true +} + +enum WalkAction { + /** Continue walking, which is the default */ + Continue, + + /** Skip visiting the children of this node */ + Skip, + + /** Stop the walk entirely */ + Stop, +} + +function walk( + obj: Record, + path: string[] = [], + callback: (value: unknown, path: string[]) => WalkAction | void, +) { + for (let key of Reflect.ownKeys(obj) as string[]) { + let value = obj[key] + + if (value === undefined || value === null) { + continue + } + + let keyPath = [...path, key] + + let result = callback(value, keyPath) ?? WalkAction.Continue + + if (result === WalkAction.Skip) continue + if (result === WalkAction.Stop) break + + if (!Array.isArray(value) && typeof value !== 'object') continue + + walk(value as any, keyPath, callback) + } +} diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 18eb92f7c..99324b070 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -5,6 +5,7 @@ import { createCompatConfig } from './compat/config/create-compat-config' import { resolveConfig, type ConfigFile } from './compat/config/resolve-config' import type { ResolvedConfig, UserConfig } from './compat/config/types' import { darkModePlugin } from './compat/dark-mode' +import { mergeIntoTheme } from './compat/merge-into-theme' import type { DesignSystem } from './design-system' import { createThemeFn } from './theme-fn' import { withAlpha, withNegative } from './utilities' @@ -351,10 +352,11 @@ export function registerPlugins( ast: AstNode[], configs: ConfigFile[], ) { + let userConfig = [{ config: { plugins } }, ...configs] + let resolvedConfig = resolveConfig(designSystem, [ { config: createCompatConfig(designSystem.theme) }, - ...configs, - { config: { plugins } }, + ...userConfig, { config: { plugins: [darkModePlugin] } }, ]) @@ -364,6 +366,10 @@ export function registerPlugins( handler(pluginApi) } + // Theme keys don't need to read from the compat config because core plugins + // _already_ look through "fallback" theme keys + mergeIntoTheme(designSystem, userConfig) + return { pluginApi, resolvedConfig,