diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 6f4901c25..44567da39 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -48,7 +48,7 @@ export function objectToAst(obj: CssInJs): AstNode[] { let ast: AstNode[] = [] for (let [name, value] of Object.entries(obj)) { - if (typeof value === 'string') { + if (typeof value !== 'object') { if (!name.startsWith('--') && value === '@slot') { ast.push(rule(name, [rule('@slot', [])])) } else { @@ -56,9 +56,9 @@ export function objectToAst(obj: CssInJs): AstNode[] { // https://github.com/postcss/postcss-js/blob/b3db658b932b42f6ac14ca0b1d50f50c4569805b/parser.js#L30-L35 name = name.replace(/([A-Z])/g, '-$1').toLowerCase() - ast.push(decl(name, value)) + ast.push(decl(name, String(value))) } - } else { + } else if (value !== null) { ast.push(rule(name, objectToAst(value))) } } diff --git a/packages/tailwindcss/src/compat/config/deep-freeze.ts b/packages/tailwindcss/src/compat/config/deep-freeze.ts new file mode 100644 index 000000000..f4a188a0b --- /dev/null +++ b/packages/tailwindcss/src/compat/config/deep-freeze.ts @@ -0,0 +1,14 @@ +export function deepFreeze(obj: T): T { + for (let name of Reflect.ownKeys(obj)) { + let value = obj[name as keyof T] + if ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + !Object.isFrozen(value) + ) { + deepFreeze(value) + } + } + + return Object.freeze(obj) +} diff --git a/packages/tailwindcss/src/compat/config/deep-merge.ts b/packages/tailwindcss/src/compat/config/deep-merge.ts new file mode 100644 index 000000000..49ac100b5 --- /dev/null +++ b/packages/tailwindcss/src/compat/config/deep-merge.ts @@ -0,0 +1,37 @@ +export function isPlainObject(value: T): value is T & Record { + if (Object.prototype.toString.call(value) !== '[object Object]') { + return false + } + + const prototype = Object.getPrototypeOf(value) + return prototype === null || Object.getPrototypeOf(prototype) === null +} + +export function deepMerge( + target: T, + sources: (Partial | null | undefined)[], + customizer: (a: any, b: any) => any, +) { + type Key = keyof T + type Value = T[Key] + + for (let source of sources) { + if (source === null || source === undefined) { + continue + } + + for (let k of Reflect.ownKeys(source) as Key[]) { + let merged = customizer(target[k], source[k]) + + if (merged !== undefined) { + target[k] = merged + } else if (!isPlainObject(target[k]) || !isPlainObject(source[k])) { + target[k] = source[k] as Value + } else { + target[k] = deepMerge({}, [target[k], source[k]], customizer) as Value + } + } + } + + return target +} diff --git a/packages/tailwindcss/src/compat/config/resolve-config.test.ts b/packages/tailwindcss/src/compat/config/resolve-config.test.ts new file mode 100644 index 000000000..ad37d16d9 --- /dev/null +++ b/packages/tailwindcss/src/compat/config/resolve-config.test.ts @@ -0,0 +1,141 @@ +import { test } from 'vitest' +import { resolveConfig } from './resolve-config' + +test('top level theme keys are replaced', ({ expect }) => { + let config = resolveConfig([ + { + theme: { + colors: { + red: 'red', + }, + + fontFamily: { + sans: 'SF Pro Display', + }, + }, + }, + { + theme: { + colors: { + green: 'green', + }, + }, + }, + { + theme: { + colors: { + blue: 'blue', + }, + }, + }, + ]) + + expect(config).toMatchObject({ + theme: { + colors: { + blue: 'blue', + }, + fontFamily: { + sans: 'SF Pro Display', + }, + }, + }) +}) + +test('theme can be extended', ({ expect }) => { + let config = resolveConfig([ + { + theme: { + colors: { + red: 'red', + }, + + fontFamily: { + sans: 'SF Pro Display', + }, + }, + }, + { + theme: { + extend: { + colors: { + blue: 'blue', + }, + }, + }, + }, + ]) + + expect(config).toMatchObject({ + theme: { + colors: { + red: 'red', + blue: 'blue', + }, + fontFamily: { + sans: 'SF Pro Display', + }, + }, + }) +}) + +test('theme keys can reference other theme keys using the theme function regardless of order', ({ + expect, +}) => { + let config = resolveConfig([ + { + theme: { + colors: { + red: 'red', + }, + placeholderColor: { + green: 'green', + }, + }, + }, + { + theme: { + extend: { + colors: ({ theme }) => ({ + ...theme('placeholderColor'), + blue: 'blue', + }), + }, + }, + }, + { + theme: { + extend: { + caretColor: ({ theme }) => theme('accentColor'), + accentColor: ({ theme }) => theme('backgroundColor'), + backgroundColor: ({ theme }) => theme('colors'), + }, + }, + }, + ]) + + expect(config).toMatchObject({ + theme: { + colors: { + red: 'red', + green: 'green', + blue: 'blue', + }, + accentColor: { + red: 'red', + green: 'green', + blue: 'blue', + }, + backgroundColor: { + red: 'red', + green: 'green', + blue: 'blue', + }, + caretColor: { + red: 'red', + green: 'green', + blue: 'blue', + }, + }, + }) +}) diff --git a/packages/tailwindcss/src/compat/config/resolve-config.ts b/packages/tailwindcss/src/compat/config/resolve-config.ts new file mode 100644 index 000000000..ceef4401c --- /dev/null +++ b/packages/tailwindcss/src/compat/config/resolve-config.ts @@ -0,0 +1,228 @@ +import { segment } from '../../utils/segment' +import { deepMerge, isPlainObject } from './deep-merge' +import { + type LoadedPlugin, + type LoadedPreset, + type RawPlugin, + type ResolvedConfig, + type ResolvedContentConfig, + type ResolvedThemeValue, + type ThemeValue, + type UserConfig, +} from './types' + +interface ResolutionContext { + configs: UserConfig[] + plugins: RawPlugin[] + content: ResolvedContentConfig + theme: Record + extend: Record + result: ResolvedConfig +} + +let minimal: ResolvedConfig = { + important: false, + content: { + files: [], + transform: {}, + }, + blocklist: [], + prefix: '', + separator: ':', + darkMode: 'media', + theme: {}, + plugins: [], +} + +export function resolveConfig(configs: UserConfig[]): ResolvedConfig { + let ctx: ResolutionContext = { + configs: [], + plugins: [], + content: { + files: [], + transform: {}, + }, + theme: {}, + extend: {}, + + // Start with a minimal valid, but empty config + result: structuredClone(minimal), + } + + // Gather a list of mergable configs + // Gather a final list of plugins + for (let config of configs) { + resolveInternal(config, ctx) + } + + // Merge top-level keys + mergeTopLevel(ctx) + + // Merge content + mergeContent(ctx) + + // Merge themes + mergeTheme(ctx) + + return { + ...ctx.result, + theme: ctx.theme as ResolvedConfig['theme'], + content: ctx.content, + plugins: ctx.plugins, + } +} + +function mergeTopLevel(ctx: ResolutionContext) { + for (let config of ctx.configs) { + Object.assign(ctx.result, config) + } +} + +function mergeContent(ctx: ResolutionContext) { + let resolved = ctx.content + + for (let config of ctx.configs) { + let content = config.content ?? [] + let files = Array.isArray(content) ? content : content.files + let transform = Array.isArray(content) ? {} : content.transform + + resolved.files.push(...files) + Object.assign(resolved.transform, transform) + } +} + +function mergeThemeExtension( + themeValue: ThemeValue | ThemeValue[], + extensionValue: ThemeValue | ThemeValue[], +) { + // When we have an array of objects, we do want to merge it + if (Array.isArray(themeValue) && isPlainObject(themeValue[0])) { + return themeValue.concat(extensionValue) + } + + // When the incoming value is an array, and the existing config is an object, prepend the existing object + if ( + Array.isArray(extensionValue) && + isPlainObject(extensionValue[0]) && + isPlainObject(themeValue) + ) { + return [themeValue, ...extensionValue] + } + + // Override arrays (for example for font-families, box-shadows, ...) + if (Array.isArray(extensionValue)) { + return extensionValue + } + + // Execute default behaviour + return undefined +} + +export interface PluginUtils { + theme(keypath: string, defaultValue?: any): any +} + +function createThemeFn(ctx: ResolutionContext, resolveValue: (value: any) => any) { + return function theme(path: string): any { + let parts = segment(path, '.') + let value: any = ctx.theme + + for (let key of parts) { + // The value isn't an object which means we can't traverse it + if (typeof value !== 'object' || value === null) { + return null + } + + // The path contains a key that doesn't exist + if (!(key in value)) { + return null + } + + value = value[key] + } + + return resolveValue(value) + } +} + +function mergeTheme(ctx: ResolutionContext) { + let api: PluginUtils = { + theme: createThemeFn(ctx, resolveValue), + } + + function resolveValue(value: ThemeValue | null | undefined): ResolvedThemeValue { + if (typeof value === 'function') { + return value(api) ?? null + } + + return value ?? null + } + + for (let config of ctx.configs) { + let theme = config.theme ?? {} + let extend = theme.extend ?? {} + + // Shallow merge themes so latest "group" wins + Object.assign(ctx.theme, theme) + + // Collect extensions by key so each + // group can be lazily deep merged + for (let key in extend) { + ctx.extend[key] ??= [] + ctx.extend[key].push(extend[key]) + } + } + + // Remove the `extend` key from the theme + // It's only used for merging and should + // not be present in the resolved theme + delete ctx.theme.extend + + // Deep merge every `extend` key into the theme + for (let key in ctx.extend) { + let values = [ctx.theme[key], ...ctx.extend[key]] + + ctx.theme[key] = () => { + let v = values.map(resolveValue) + + let result = deepMerge({}, v, mergeThemeExtension) + return result + } + } + + for (let key in ctx.theme) { + ctx.theme[key] = resolveValue(ctx.theme[key]) + } +} + +function resolveInternal(user: UserConfig, ctx: ResolutionContext): void { + let presets = (user.presets ?? []) as LoadedPreset[] + let plugins = (user.plugins ?? []) as LoadedPlugin[] + + // Presets are applied first + // They may define presets, plugins, and configs + for (let preset of presets) { + resolveInternal(preset, ctx) + } + + // Plugins are applied next + // They may define presets, plugins, and configs + for (let plugin of plugins) { + if (typeof plugin === 'function') { + ctx.plugins.push(plugin) + } else { + ctx.plugins.push(plugin.handler) + + if (plugin.config) { + resolveInternal(plugin.config(), ctx) + } + } + } + + // The "user" config is then applied + // This config may actually represent: + // - a preset config + // - a plugin config + // - a user config + ctx.configs.push(user) +} diff --git a/packages/tailwindcss/src/compat/config/types.ts b/packages/tailwindcss/src/compat/config/types.ts new file mode 100644 index 000000000..54146309a --- /dev/null +++ b/packages/tailwindcss/src/compat/config/types.ts @@ -0,0 +1,76 @@ +import type { PluginUtils } from './resolve-config' + +export type ImportableFile = string | [path: string, options: Record] + +type PluginAPI = {} + +type FilePath = string +type RawFile = { raw: string; extension?: string } +export type RawPreset = Omit +export type Preset = RawPreset | ImportableFile +type PluginConfig = Omit + +export type RawPlugin = (api: PluginAPI) => void +export type ConfigurablePlugin = { + handler: RawPlugin + config?: () => PluginConfig +} + +export type Plugin = RawPlugin | ConfigurablePlugin | ImportableFile + +export type LoadedPreset = RawPreset + +export type LoadedPlugin = RawPlugin | ConfigurablePlugin + +export type ContentFile = 'auto' | FilePath | RawFile + +export type ContentTransform = (content: string) => string + +type ContentConfig = ContentFile[] | ResolvedContentConfig + +export interface ResolvedContentConfig { + files: ContentFile[] + transform: Record +} + +// DarkMode related config +export type DarkModeConfig = + // Use the `media` query strategy. + | 'media' + // Use the `class` strategy, which requires a `.dark` class on the `html`. + | 'class' + // Use the `class` strategy with a custom class instead of `.dark`. + | ['class', string] + +export type { PluginUtils } + +export type ResolvableTo = T | ((utils: PluginUtils) => T) + +export interface UserConfig { + important?: boolean + content?: ContentConfig + prefix?: string + separator?: ':' | '_' + darkMode?: DarkModeConfig + presets?: Preset[] + plugins?: Plugin[] + theme?: ThemeConfig +} + +export type ThemeValue = ResolvableTo> | null | undefined +export type ResolvedThemeValue = Record | null + +export type ThemeConfig = Record & { + extend?: Record +} + +export interface ResolvedConfig { + important: boolean + content: ResolvedContentConfig + blocklist: string[] + prefix: string + separator: ':' | '_' + darkMode: DarkModeConfig + plugins: RawPlugin[] + theme: Record> +} diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index 80f923e1a..4c935ef8c 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -14,7 +14,7 @@ export function compileCandidates( ) { let nodeSorting = new Map< AstNode, - { properties: number[]; variants: bigint; candidate: string } + { properties: [number[], number[]]; variants: bigint; candidate: string } >() let astNodes: AstNode[] = [] let candidates = new Map() @@ -68,6 +68,9 @@ export function compileCandidates( let aSorting = nodeSorting.get(a)! let zSorting = nodeSorting.get(z)! + let [aProperties, aCounts] = aSorting.properties + let [zProperties, zCounts] = zSorting.properties + // Sort by variant order first if (aSorting.variants - zSorting.variants !== 0n) { return Number(aSorting.variants - zSorting.variants) @@ -76,21 +79,29 @@ export function compileCandidates( // Find the first property that is different between the two rules let offset = 0 while ( - aSorting.properties.length < offset && - zSorting.properties.length < offset && - aSorting.properties[offset] === zSorting.properties[offset] + aProperties.length < offset && + zProperties.length < offset && + aProperties[offset] === zProperties[offset] ) { offset += 1 } - return ( - // Sort by lowest property index first - (aSorting.properties[offset] ?? Infinity) - (zSorting.properties[offset] ?? Infinity) || - // Sort by most properties first, then by least properties - zSorting.properties.length - aSorting.properties.length || - // Sort alphabetically - compare(aSorting.candidate, zSorting.candidate) - ) + // Sort by lowest property index first + let lowestPropertyDelta = (aProperties[offset] ?? Infinity) - (zProperties[offset] ?? Infinity) + if (lowestPropertyDelta) return lowestPropertyDelta + + // Sort by most properties first, then by least properties + let uniquePropertyDelta = zProperties.length - aProperties.length + if (uniquePropertyDelta) return uniquePropertyDelta + + // If both have the same unique properties, sort based on instances of those properties + for (let i = 0; i < aProperties.length; i++) { + let delta = zCounts[i] - aCounts[i] + if (delta) return delta + } + + // Sort alphabetically + return compare(aSorting.candidate, zSorting.candidate) }) return { @@ -243,9 +254,9 @@ function applyImportant(ast: AstNode[]): void { } } -function getPropertySort(nodes: AstNode[]) { +function getPropertySort(nodes: AstNode[]): [number[], number[]] { // Determine sort order based on properties used - let propertySort = new Set() + let propertySort = new Map() let q: AstNode[] = nodes.slice() while (q.length > 0) { @@ -256,13 +267,13 @@ function getPropertySort(nodes: AstNode[]) { if (node.property === '--tw-sort') { let idx = GLOBAL_PROPERTY_ORDER.indexOf(node.value) if (idx !== -1) { - propertySort.add(idx) + propertySort.set(idx, (propertySort.get(idx) ?? 0) + 1) break } } let idx = GLOBAL_PROPERTY_ORDER.indexOf(node.property) - if (idx !== -1) propertySort.add(idx) + if (idx !== -1) propertySort.set(idx, (propertySort.get(idx) ?? 0) + 1) } else if (node.kind === 'rule') { // Don't consider properties within `@at-root` when determining the sort // order for a rule. @@ -274,5 +285,10 @@ function getPropertySort(nodes: AstNode[]) { } } - return Array.from(propertySort).sort((a, z) => a - z) + let sorted = Array.from(propertySort).sort(([a, _a], [z, _z]) => a - z) + + return [ + sorted.map(([propertySort]) => propertySort), + sorted.map(([, propertyCount]) => propertyCount), + ] } diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts new file mode 100644 index 000000000..d6d07996b --- /dev/null +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -0,0 +1,909 @@ +import { describe, expect, test, vi } from 'vitest' +import { compile } from '.' +import plugin from './plugin' +import type { PluginAPI } from './plugin-api' +import { optimizeCss } from './test-utils/run' + +const css = String.raw + +describe('theme', async () => { + test('plugin theme can contain objects', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ addBase, theme }) { + addBase({ + '@keyframes enter': theme('keyframes.enter'), + '@keyframes exit': theme('keyframes.exit'), + }) + }, + { + theme: { + extend: { + keyframes: { + enter: { + from: { + opacity: 'var(--tw-enter-opacity, 1)', + transform: + 'translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))', + }, + }, + exit: { + to: { + opacity: 'var(--tw-exit-opacity, 1)', + transform: + 'translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))', + }, + }, + }, + }, + }, + }, + ) + }, + }) + + expect(compiler.build([])).toMatchInlineSnapshot(` + "@layer base { + @keyframes enter { + from { + opacity: var(--tw-enter-opacity, 1); + transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0)); + } + } + @keyframes exit { + to { + opacity: var(--tw-exit-opacity, 1); + transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0)); + } + } + } + " + `) + }) + + test('plugin theme can extend colors', async ({ expect }) => { + let input = css` + @theme reference { + --color-red-500: #ef4444; + } + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + scrollbar: (value) => ({ 'scrollbar-color': value }), + }, + { + values: theme('colors'), + }, + ) + }, + { + theme: { + extend: { + colors: { + 'russet-700': '#7a4724', + }, + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['scrollbar-red-500', 'scrollbar-russet-700'])).toMatchInlineSnapshot(` + ".scrollbar-red-500 { + scrollbar-color: var(--color-red-500, #ef4444); + } + .scrollbar-russet-700 { + scrollbar-color: #7a4724; + } + " + `) + }) + + test('plugin theme values can reference legacy theme keys that have been replaced with bare value support', async ({ + expect, + }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + 'animate-duration': (value) => ({ 'animation-duration': value }), + }, + { + values: theme('animationDuration'), + }, + ) + }, + { + theme: { + extend: { + animationDuration: ({ theme }: { theme: (path: string) => any }) => { + return { + ...theme('transitionDuration'), + } + }, + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['animate-duration-316'])).toMatchInlineSnapshot(` + ".animate-duration-316 { + animation-duration: 316ms; + } + " + `) + }) + + test('plugin theme values that support bare values are merged with other values for that theme key', async ({ + expect, + }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + 'animate-duration': (value) => ({ 'animation-duration': value }), + }, + { + values: theme('animationDuration'), + }, + ) + }, + { + theme: { + extend: { + transitionDuration: { + slow: '800ms', + }, + + animationDuration: ({ theme }: { theme: (path: string) => any }) => ({ + ...theme('transitionDuration'), + }), + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['animate-duration-316', 'animate-duration-slow'])) + .toMatchInlineSnapshot(` + ".animate-duration-316 { + animation-duration: 316ms; + } + .animate-duration-slow { + animation-duration: 800ms; + } + " + `) + }) + + test('theme value functions are resolved correctly regardless of order', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + 'animate-delay': (value) => ({ 'animation-delay': value }), + }, + { + values: theme('animationDelay'), + }, + ) + }, + { + theme: { + extend: { + animationDuration: ({ theme }: { theme: (path: string) => any }) => ({ + ...theme('transitionDuration'), + }), + + animationDelay: ({ theme }: { theme: (path: string) => any }) => ({ + ...theme('animationDuration'), + }), + + transitionDuration: { + slow: '800ms', + }, + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['animate-delay-316', 'animate-delay-slow'])).toMatchInlineSnapshot(` + ".animate-delay-316 { + animation-delay: 316ms; + } + .animate-delay-slow { + animation-delay: 800ms; + } + " + `) + }) + + test('plugins can override the default key', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + 'animate-duration': (value) => ({ 'animation-delay': value }), + }, + { + values: theme('transitionDuration'), + }, + ) + }, + { + theme: { + extend: { + transitionDuration: { + DEFAULT: '1500ms', + }, + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['animate-duration'])).toMatchInlineSnapshot(` + ".animate-duration { + animation-delay: 1500ms; + } + " + `) + }) + + test('plugins can read CSS theme keys using the old theme key notation', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --animation: pulse 1s linear infinite; + --animation-spin: spin 1s linear infinite; + } + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin(function ({ matchUtilities, theme }) { + matchUtilities( + { + animation: (value) => ({ animation: value }), + }, + { + values: theme('animation'), + }, + ) + + matchUtilities( + { + animation2: (value) => ({ animation: value }), + }, + { + values: { + DEFAULT: theme('animation.DEFAULT'), + twist: theme('animation.spin'), + }, + }, + ) + }) + }, + }) + + expect(compiler.build(['animation-spin', 'animation', 'animation2', 'animation2-twist'])) + .toMatchInlineSnapshot(` + ".animation { + animation: var(--animation, pulse 1s linear infinite); + } + .animation-spin { + animation: var(--animation-spin, spin 1s linear infinite); + } + .animation2 { + animation: var(--animation, pulse 1s linear infinite); + } + .animation2-twist { + animation: var(--animation-spin, spin 1s linear infinite); + } + " + `) + }) + + test('CSS theme values are mreged with JS theme values', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --animation: pulse 1s linear infinite; + --animation-spin: spin 1s linear infinite; + } + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + animation: (value) => ({ '--animation': value }), + }, + { + values: theme('animation'), + }, + ) + }, + { + theme: { + extend: { + animation: { + bounce: 'bounce 1s linear infinite', + }, + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['animation', 'animation-spin', 'animation-bounce'])) + .toMatchInlineSnapshot(` + ".animation { + --animation: var(--animation, pulse 1s linear infinite); + } + .animation-bounce { + --animation: bounce 1s linear infinite; + } + .animation-spin { + --animation: var(--animation-spin, spin 1s linear infinite); + } + " + `) + }) + + test('CSS theme defaults take precedence over JS theme defaults', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --animation: pulse 1s linear infinite; + --animation-spin: spin 1s linear infinite; + } + ` + + let compiler = await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { + animation: (value) => ({ '--animation': value }), + }, + { + values: theme('animation'), + }, + ) + }, + { + theme: { + extend: { + animation: { + DEFAULT: 'twist 1s linear infinite', + }, + }, + }, + }, + ) + }, + }) + + expect(compiler.build(['animation'])).toMatchInlineSnapshot(` + ".animation { + --animation: var(--animation, pulse 1s linear infinite); + } + " + `) + }) + + test('CSS theme values take precedence even over non-object JS values', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --animation-simple-spin: spin 1s linear infinite; + --animation-simple-bounce: bounce 1s linear infinite; + } + ` + + let fn = vi.fn() + + await compile(input, { + loadPlugin: async () => { + return plugin( + function ({ theme }) { + fn(theme('animation.simple')) + }, + { + theme: { + extend: { + animation: { + simple: 'simple 1s linear', + }, + }, + }, + }, + ) + }, + }) + + expect(fn).toHaveBeenCalledWith({ + spin: 'var(--animation-simple-spin, spin 1s linear infinite)', + bounce: 'var(--animation-simple-bounce, bounce 1s linear infinite)', + }) + }) + + test('all necessary theme keys support bare values', async ({ expect }) => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let { build } = await compile(input, { + loadPlugin: async () => { + return plugin(function ({ matchUtilities, theme }) { + function utility(name: string, themeKey: string) { + matchUtilities( + { [name]: (value) => ({ '--value': value }) }, + { values: theme(themeKey) }, + ) + } + + utility('my-aspect', 'aspectRatio') + utility('my-backdrop-brightness', 'backdropBrightness') + utility('my-backdrop-contrast', 'backdropContrast') + utility('my-backdrop-grayscale', 'backdropGrayscale') + utility('my-backdrop-hue-rotate', 'backdropHueRotate') + utility('my-backdrop-invert', 'backdropInvert') + utility('my-backdrop-opacity', 'backdropOpacity') + utility('my-backdrop-saturate', 'backdropSaturate') + utility('my-backdrop-sepia', 'backdropSepia') + utility('my-border', 'border') + utility('my-brightness', 'brightness') + utility('my-columns', 'columns') + utility('my-contrast', 'contrast') + utility('my-divide-width', 'divideWidth') + utility('my-flex-grow', 'flexGrow') + utility('my-flex-shrink', 'flexShrink') + utility('my-gradient-color-stop-positions', 'gradientColorStopPositions') + utility('my-grayscale', 'grayscale') + utility('my-grid-row-end', 'gridRowEnd') + utility('my-grid-row-start', 'gridRowStart') + utility('my-grid-template-columns', 'gridTemplateColumns') + utility('my-grid-template-rows', 'gridTemplateRows') + utility('my-hue-rotate', 'hueRotate') + utility('my-invert', 'invert') + utility('my-line-clamp', 'lineClamp') + utility('my-opacity', 'opacity') + utility('my-order', 'order') + utility('my-outline-offset', 'outlineOffset') + utility('my-outline-width', 'outlineWidth') + utility('my-ring-offset-width', 'ringOffsetWidth') + utility('my-ring-width', 'ringWidth') + utility('my-rotate', 'rotate') + utility('my-saturate', 'saturate') + utility('my-scale', 'scale') + utility('my-sepia', 'sepia') + utility('my-skew', 'skew') + utility('my-stroke-width', 'strokeWidth') + utility('my-text-decoration-thickness', 'textDecorationThickness') + utility('my-text-underline-offset', 'textUnderlineOffset') + utility('my-transition-delay', 'transitionDelay') + utility('my-transition-duration', 'transitionDuration') + utility('my-z-index', 'zIndex') + }) + }, + }) + + let output = build([ + 'my-aspect-2/5', + 'my-backdrop-brightness-1', + 'my-backdrop-contrast-1', + 'my-backdrop-grayscale-1', + 'my-backdrop-hue-rotate-1', + 'my-backdrop-invert-1', + 'my-backdrop-opacity-1', + 'my-backdrop-saturate-1', + 'my-backdrop-sepia-1', + 'my-border-1', + 'my-brightness-1', + 'my-columns-1', + 'my-contrast-1', + 'my-divide-width-1', + 'my-flex-grow-1', + 'my-flex-shrink-1', + 'my-gradient-color-stop-positions-1', + 'my-grayscale-1', + 'my-grid-row-end-1', + 'my-grid-row-start-1', + 'my-grid-template-columns-1', + 'my-grid-template-rows-1', + 'my-hue-rotate-1', + 'my-invert-1', + 'my-line-clamp-1', + 'my-opacity-1', + 'my-order-1', + 'my-outline-offset-1', + 'my-outline-width-1', + 'my-ring-offset-width-1', + 'my-ring-width-1', + 'my-rotate-1', + 'my-saturate-1', + 'my-scale-1', + 'my-sepia-1', + 'my-skew-1', + 'my-stroke-width-1', + 'my-text-decoration-thickness-1', + 'my-text-underline-offset-1', + 'my-transition-delay-1', + 'my-transition-duration-1', + 'my-z-index-1', + ]) + + expect(output).toMatchInlineSnapshot(` + ".my-aspect-2\\/5 { + --value: 2/5; + } + .my-backdrop-brightness-1 { + --value: 1%; + } + .my-backdrop-contrast-1 { + --value: 1%; + } + .my-backdrop-grayscale-1 { + --value: 1%; + } + .my-backdrop-hue-rotate-1 { + --value: 1deg; + } + .my-backdrop-invert-1 { + --value: 1%; + } + .my-backdrop-opacity-1 { + --value: 1%; + } + .my-backdrop-saturate-1 { + --value: 1%; + } + .my-backdrop-sepia-1 { + --value: 1%; + } + .my-border-1 { + --value: 1px; + } + .my-brightness-1 { + --value: 1%; + } + .my-columns-1 { + --value: 1; + } + .my-contrast-1 { + --value: 1%; + } + .my-divide-width-1 { + --value: 1px; + } + .my-flex-grow-1 { + --value: 1; + } + .my-flex-shrink-1 { + --value: 1; + } + .my-gradient-color-stop-positions-1 { + --value: 1%; + } + .my-grayscale-1 { + --value: 1%; + } + .my-grid-row-end-1 { + --value: 1; + } + .my-grid-row-start-1 { + --value: 1; + } + .my-grid-template-columns-1 { + --value: repeat(1, minmax(0, 1fr)); + } + .my-grid-template-rows-1 { + --value: repeat(1, minmax(0, 1fr)); + } + .my-hue-rotate-1 { + --value: 1deg; + } + .my-invert-1 { + --value: 1%; + } + .my-line-clamp-1 { + --value: 1; + } + .my-opacity-1 { + --value: 1%; + } + .my-order-1 { + --value: 1; + } + .my-outline-offset-1 { + --value: 1px; + } + .my-outline-width-1 { + --value: 1px; + } + .my-ring-offset-width-1 { + --value: 1px; + } + .my-ring-width-1 { + --value: 1px; + } + .my-rotate-1 { + --value: 1deg; + } + .my-saturate-1 { + --value: 1%; + } + .my-scale-1 { + --value: 1%; + } + .my-sepia-1 { + --value: 1%; + } + .my-skew-1 { + --value: 1deg; + } + .my-stroke-width-1 { + --value: 1px; + } + .my-text-decoration-thickness-1 { + --value: 1px; + } + .my-text-underline-offset-1 { + --value: 1px; + } + .my-transition-delay-1 { + --value: 1ms; + } + .my-transition-duration-1 { + --value: 1ms; + } + .my-z-index-1 { + --value: 1; + } + " + `) + }) +}) + +describe('matchComponents', async () => { + test('example typography plugin', async () => { + async function run(candidates: string[]) { + let compiled = await compile( + css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --font-size-sm: 14px; + --font-size-sm--line-height: 18px; + --spacing-8: 32px; + --font-weight-bold: 700; + --radius-lg: 16px; + } + `, + { + async loadPlugin() { + return plugin( + ({ addComponents, theme }: PluginAPI) => { + addComponents({ + '.prose': theme('typography.base.css'), + }) + }, + { + theme: { + typography: { + base: { + css: { + fontSize: '16px', + lineHeight: '24px', + '* + *': { + marginTop: '32px', + }, + h1: { + fontSize: '32px', + lineHeight: '36px', + }, + h2: { + fontSize: '24px', + lineHeight: '28px', + }, + img: { + borderRadius: '12px', + }, + }, + }, + }, + }, + }, + ) + }, + }, + ) + + return compiled.build(candidates) + } + + expect(optimizeCss(await run(['font-bold', 'text-sm', 'rounded-lg', 'mt-8', 'prose'])).trim()) + .toMatchInlineSnapshot(` + ".prose { + font-size: 16px; + line-height: 24px; + } + + .prose * + * { + margin-top: 32px; + } + + .prose h1 { + font-size: 32px; + line-height: 36px; + } + + .prose h2 { + font-size: 24px; + line-height: 28px; + } + + .prose img { + border-radius: 12px; + } + + .mt-8 { + margin-top: var(--spacing-8, 32px); + } + + .rounded-lg { + border-radius: var(--radius-lg, 16px); + } + + .text-sm { + font-size: var(--font-size-sm, 14px); + line-height: var(--font-size-sm--line-height, 18px); + } + + .font-bold { + font-weight: var(--font-weight-bold, 700); + }" + `) + }) + + test('example container plugin', async () => { + async function run(candidates: string[]) { + let compiled = await compile( + css` + @tailwind utilities; + @plugin "my-plugin"; + @theme reference { + --width-sm: 24rem; + } + `, + { + async loadPlugin() { + return plugin(({ addComponents }: PluginAPI) => { + addComponents({ + '.zontainer': { + maxWidth: '100%', + '@media (min-width: 640px)': { + maxWidth: '640px', + }, + '@media (min-width: 768px)': { + maxWidth: '768px', + }, + '@media (min-width: 1024px)': { + maxWidth: '1024px', + }, + '@media (min-width: 1280px)': { + maxWidth: '1280px', + }, + '@media (min-width: 1536px)': { + maxWidth: '1536px', + }, + }, + }) + }) + }, + }, + ) + + return compiled.build(candidates) + } + + expect(optimizeCss(await run(['max-w-sm', 'zontainer'])).trim()).toMatchInlineSnapshot(` + ".zontainer { + max-width: 100%; + } + + @media (width >= 640px) { + .zontainer { + max-width: 640px; + } + } + + @media (width >= 768px) { + .zontainer { + max-width: 768px; + } + } + + @media (width >= 1024px) { + .zontainer { + max-width: 1024px; + } + } + + @media (width >= 1280px) { + .zontainer { + max-width: 1280px; + } + } + + @media (width >= 1536px) { + .zontainer { + max-width: 1536px; + } + } + + .max-w-sm { + max-width: var(--width-sm, 24rem); + }" + `) + }) +}) diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 3599c7a20..71399dba8 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -1,13 +1,19 @@ import { substituteAtApply } from './apply' import { objectToAst, rule, type AstNode, type CssInJs } from './ast' +import type { NamedUtilityValue } from './candidate' +import { deepMerge } from './compat/config/deep-merge' +import { resolveConfig } from './compat/config/resolve-config' +import type { UserConfig } from './compat/config/types' import type { DesignSystem } from './design-system' +import type { Theme } from './theme' import { withAlpha, withNegative } from './utilities' +import { DefaultMap } from './utils/default-map' import { inferDataType } from './utils/infer-data-type' +import { segment } from './utils/segment' -export type Config = Record - +export type Config = UserConfig export type PluginFn = (api: PluginAPI) => void -export type PluginWithConfig = { handler: PluginFn; config?: Partial } +export type PluginWithConfig = { handler: PluginFn; config?: UserConfig } export type PluginWithOptions = { (options?: T): PluginWithConfig __isOptionsFunction: true @@ -24,15 +30,174 @@ export type PluginAPI = { options?: Partial<{ type: string | string[] supportsNegativeValues: boolean - values: Record + values: { __BARE_VALUE__?: (value: NamedUtilityValue) => string | undefined } & Record< + string, + string + > modifiers: 'any' | Record }>, ): void + theme(path: string): any + addComponents(components: Record, options?: {}): void + matchComponents( + utilities: Record CssInJs>, + options?: Partial<{ + type: string | string[] + supportsNegativeValues: boolean + values: { __BARE_VALUE__?: (value: NamedUtilityValue) => string | undefined } & Record< + string, + string + > + modifiers: 'any' | Record + }>, + ): void + theme(path: string): any } const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ -export function buildPluginApi(designSystem: DesignSystem, ast: AstNode[]): PluginAPI { +export function buildPluginApi( + designSystem: DesignSystem, + ast: AstNode[], + resolvedConfig: { theme?: Record }, +): PluginAPI { + let addUtilities: PluginAPI['addUtilities'] = (utilities) => { + for (let [name, css] of Object.entries(utilities)) { + if (name.startsWith('@keyframes ')) { + ast.push(rule(name, objectToAst(css))) + continue + } + + if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) { + throw new Error( + `\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`, + ) + } + + designSystem.utilities.static(name.slice(1), (candidate) => { + if (candidate.negative) return + + let ast = objectToAst(css) + substituteAtApply(ast, designSystem) + return ast + }) + } + } + + let matchUtilities: PluginAPI['matchUtilities'] = (utilities, options) => { + let types = options?.type + ? Array.isArray(options?.type) + ? options.type + : [options.type] + : ['any'] + + for (let [name, fn] of Object.entries(utilities)) { + if (!IS_VALID_UTILITY_NAME.test(name)) { + throw new Error( + `\`matchUtilities({ '${name}' : … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`, + ) + } + + designSystem.utilities.functional(name, (candidate) => { + // A negative utility was provided but is unsupported + if (!options?.supportsNegativeValues && candidate.negative) return + + // Throw out any candidate whose value is not a supported type + if (candidate.value?.kind === 'arbitrary' && types.length > 0 && !types.includes('any')) { + // The candidate has an explicit data type but it's not in the list + // of supported types by this utility. For example, a `scrollbar` + // utility that is only used to change the scrollbar color but is + // used with a `length` value: `scrollbar-[length:var(--whatever)]` + if (candidate.value.dataType && !types.includes(candidate.value.dataType)) { + return + } + + // The candidate does not have an explicit data type and the value + // cannot be inferred as one of the supported types. For example, a + // `scrollbar` utility that is only used to change the scrollbar + // color but is used with a `length` value: `scrollbar-[33px]` + if (!candidate.value.dataType && !inferDataType(candidate.value.value, types as any[])) { + return + } + } + + let isColor = types.includes('color') + + // Resolve the candidate value + let value: string | null = null + let isFraction = false + + { + let values = options?.values ?? {} + + if (isColor) { + // Color utilities implicitly support `inherit`, `transparent`, and `currentColor` + // for backwards compatibility but still allow them to be overridden + values = Object.assign( + { + inherit: 'inherit', + transparent: 'transparent', + current: 'currentColor', + }, + values, + ) + } + + if (!candidate.value) { + value = values.DEFAULT ?? null + } else if (candidate.value.kind === 'arbitrary') { + value = candidate.value.value + } else if (values[candidate.value.value]) { + value = values[candidate.value.value] + } else if (values.__BARE_VALUE__) { + value = values.__BARE_VALUE__(candidate.value) ?? null + + isFraction = (candidate.value.fraction !== null && value?.includes('/')) ?? false + } + } + + if (value === null) return + + // Resolve the modifier value + let modifier: string | null + + { + let modifiers = options?.modifiers ?? null + + if (!candidate.modifier) { + modifier = null + } else if (modifiers === 'any' || candidate.modifier.kind === 'arbitrary') { + modifier = candidate.modifier.value + } else if (modifiers?.[candidate.modifier.value]) { + modifier = modifiers[candidate.modifier.value] + } else if (isColor && !Number.isNaN(Number(candidate.modifier.value))) { + modifier = `${candidate.modifier.value}%` + } else { + modifier = null + } + } + + // A modifier was provided but is invalid + if (candidate.modifier && modifier === null && !isFraction) { + // For arbitrary values, return `null` to avoid falling through to the next utility + return candidate.value?.kind === 'arbitrary' ? null : undefined + } + + if (isColor && modifier !== null) { + value = withAlpha(value, modifier) + } + + if (candidate.negative) { + value = withNegative(value, candidate) + } + + let ast = objectToAst(fn(value, { modifier })) + substituteAtApply(ast, designSystem) + return ast + }) + } + } + return { addBase(css) { ast.push(rule('@layer base', objectToAst(css))) @@ -59,154 +224,381 @@ export function buildPluginApi(designSystem: DesignSystem, ast: AstNode[]): Plug } }, - addUtilities(utilities) { - for (let [name, css] of Object.entries(utilities)) { - if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) { - throw new Error( - `\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`, - ) - } + addUtilities, + matchUtilities, + addComponents: addUtilities, + matchComponents: matchUtilities, - designSystem.utilities.static(name.slice(1), (candidate) => { - if (candidate.negative) return + theme(path) { + let cssValue = readFromCss(designSystem.theme, path) - let ast = objectToAst(css) - substituteAtApply(ast, designSystem) - return ast - }) + if (typeof cssValue !== 'object') { + return cssValue } - }, - matchUtilities(utilities, options) { - let types = options?.type - ? Array.isArray(options?.type) - ? options.type - : [options.type] - : ['any'] + let result = resolvedConfig.theme ?? {} - for (let [name, fn] of Object.entries(utilities)) { - if (!IS_VALID_UTILITY_NAME.test(name)) { - throw new Error( - `\`matchUtilities({ '${name}' : … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`, - ) - } + // 1. Convert `path` to an array of parts (segment) + // 2. Find the "obeject" that corresponds to the path + // 3. Deep merge the object with the resolved theme + let parts = segment(path, '.') - designSystem.utilities.functional(name, (candidate) => { - // A negative utility was provided but is unsupported - if (!options?.supportsNegativeValues && candidate.negative) return + let obj = get(result, parts) ?? {} - // Throw out any candidate whose value is not a supported type - if (candidate.value?.kind === 'arbitrary' && types.length > 0 && !types.includes('any')) { - // The candidate has an explicit data type but it's not in the list - // of supported types by this utility. For example, a `scrollbar` - // utility that is only used to change the scrollbar color but is - // used with a `length` value: `scrollbar-[length:var(--whatever)]` - if (candidate.value.dataType && !types.includes(candidate.value.dataType)) { - return - } - - // The candidate does not have an explicit data type and the value - // cannot be inferred as one of the supported types. For example, a - // `scrollbar` utility that is only used to change the scrollbar - // color but is used with a `length` value: `scrollbar-[33px]` - if ( - !candidate.value.dataType && - !inferDataType(candidate.value.value, types as any[]) - ) { - return - } - } - - let isColor = types.includes('color') - - // Resolve the candidate value - let value: string | null - - { - let values = options?.values ?? {} - - if (isColor) { - // Color utilities implicitly support `inherit`, `transparent`, and `currentColor` - // for backwards compatibility but still allow them to be overridden - values = { - inherit: 'inherit', - transparent: 'transparent', - current: 'currentColor', - ...values, - } - } - - if (!candidate.value) { - value = values.DEFAULT ?? null - } else if (candidate.value.kind === 'arbitrary') { - value = candidate.value.value - } else { - value = values[candidate.value.value] ?? null - } - } - - if (!value) return - - // Resolve the modifier value - let modifier: string | null - - { - let modifiers = options?.modifiers ?? null - - if (!candidate.modifier) { - modifier = null - } else if (modifiers === 'any' || candidate.modifier.kind === 'arbitrary') { - modifier = candidate.modifier.value - } else if (modifiers?.[candidate.modifier.value]) { - modifier = modifiers[candidate.modifier.value] - } else if (isColor && !Number.isNaN(Number(candidate.modifier.value))) { - modifier = `${candidate.modifier.value}%` - } else { - modifier = null - } - } - - // A modifier was provided but is invalid - if (candidate.modifier && !modifier) { - // For arbitrary values, return `null` to avoid falling through to the next utility - return candidate.value?.kind === 'arbitrary' ? null : undefined - } - - if (isColor && modifier) { - value = withAlpha(value, modifier) - } - - if (candidate.negative) { - value = withNegative(value, candidate) - } - - let ast = objectToAst(fn(value, { modifier })) - substituteAtApply(ast, designSystem) - return ast - }) + if (typeof obj === 'object') { + deepMerge(obj, [cssValue], (a, b) => b) + set(result, parts, obj) + } else { + // This case happens when the CSS theme produces an object for a given + // keypath but the v3-style config does not. However we still want the + // CSS to "win" in this case so we overwrite the v3-style config value. + set(result, parts, cssValue) } + + for (let part of parts) { + result = result?.[part] + } + + return result }, } } export function registerPlugins(plugins: Plugin[], designSystem: DesignSystem, ast: AstNode[]) { - let pluginApi = buildPluginApi(designSystem, ast) + let pluginObjects = [] for (let plugin of plugins) { if ('__isOptionsFunction' in plugin) { // Happens with `plugin.withOptions()` when no options were passed: // e.g. `require("my-plugin")` instead of `require("my-plugin")(options)` - plugin().handler(pluginApi) + pluginObjects.push(plugin()) } else if ('handler' in plugin) { // Happens with `plugin(…)`: // e.g. `require("my-plugin")` // // or with `plugin.withOptions()` when the user passed options: // e.g. `require("my-plugin")(options)` - plugin.handler(pluginApi) + pluginObjects.push(plugin) } else { // Just a plain function without using the plugin(…) API - plugin(pluginApi) + pluginObjects.push({ handler: plugin, config: {} as UserConfig }) } } + + // Now merge all the configs and make all that crap work + let resolvedConfig = resolveConfig([ + createCompatabilityConfig(designSystem.theme), + ...pluginObjects.map(({ config }) => config ?? {}), + ]) + + let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig) + + // Loop over the handlers and run them all with the resolved config + CSS theme probably somehow + for (let { handler } of pluginObjects) { + handler(pluginApi) + } +} + +function readFromCss(theme: Theme, path: string) { + type ThemeValue = + // A normal string value + | string + + // A nested tuple with additional data + | [main: string, extra: Record] + // This only supports reading from the CSS Theme:… + let original = path + // Escape dots used inside square brackets + .replace(/\[(.*?)\]/g, (_, value) => `-${value.replace('.', '_')}`) + // Replace dots with dashes + .replace(/\./g, '-') + // Replace camelCase with dashes + .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) + + // Perform an "upgrade" on the path so that, for example, a request for + // accentColor merges values from --color-* and --accent-color-* + let paths: string[] = [] + + for (let prefix in themeUpgradeMap) { + if (!original.startsWith(prefix)) continue + + // This makes sure that: + // `accent-color` is turned into `color`; AND + // `accent-color-foo` is turned into `color-foo` + let suffix = original.slice(prefix.length) + + for (let upgrade of themeUpgradeMap[prefix]) { + paths.push(upgrade + suffix) + } + } + + // Make sure the original path is included last because it should take precedence + paths.push(original) + + let map = new Map() + let nested = new DefaultMap>(() => new Map()) + + for (let path of paths) { + path = path.replace('-DEFAULT', '') + + let ns = theme.resolveNamespace(`--${path}` as any) + + for (let [key, value] of ns) { + // Non-nested values can be set directly + if (!key || !key.includes('--')) { + map.set(key, value) + continue + } + + // Nested values are stored separately + let nestedIndex = key.indexOf('--') + + let mainKey = key.slice(0, nestedIndex) + let nestedKey = key.slice(nestedIndex + 2) + + // Make `nestedKey` camel case: + nestedKey = nestedKey.replace(/-([a-z])/g, (_, a) => a.toUpperCase()) + + nested.get(mainKey === '' ? null : mainKey).set(nestedKey, value) + } + } + + for (let [key, extra] of nested) { + let value = map.get(key) + if (typeof value !== 'string') continue + + map.set(key, [value, Object.fromEntries(extra)]) + } + + // We have to turn the map into object-like structure for v3 compatibility + let obj = {} + let useNestedObjects = false // paths.some((path) => nestedKeys.has(path)) + + for (let [key, value] of map) { + key = key ?? 'DEFAULT' + + let path: string[] = [] + let splitIndex = key.indexOf('-') + + if (useNestedObjects && splitIndex !== -1) { + path.push(key.slice(0, splitIndex)) + path.push(key.slice(splitIndex + 1)) + } else { + path.push(key) + } + + set(obj, path, value) + } + + if (path.endsWith('.DEFAULT') && 'DEFAULT' in obj) { + return obj.DEFAULT ?? null + } + + if (Object.keys(obj).length === 1 && 'DEFAULT' in obj) { + return obj.DEFAULT + } + + return obj +} + +function get(obj: any, path: string[]) { + for (let key of path) { + if (obj[key] === undefined) { + return undefined + } + + obj = obj[key] + } + + return obj +} + +function set(obj: any, path: string[], value: any) { + for (let key of path.slice(0, -1)) { + if (obj[key] === undefined) { + obj[key] = {} + } + + obj = obj[key] + } + + obj[path[path.length - 1]] = value +} + +let themeUpgradeMap: Record = { + colors: ['color'], +} + +function bareValues(fn: (value: NamedUtilityValue) => string | undefined) { + return { + // Ideally this would be a Symbol but some of the ecosystem assumes object with + // string / number keys for example by using `Object.entries()` which means that + // the function that handles the bare value would be lost + __BARE_VALUE__: fn, + } +} + +let bareIntegers = bareValues((value) => { + if (!Number.isNaN(Number(value.value))) { + return value.value + } +}) + +let barePercentages = bareValues((value: NamedUtilityValue) => { + if (!Number.isNaN(Number(value.value))) { + return `${value.value}%` + } +}) + +let barePixels = bareValues((value: NamedUtilityValue) => { + if (!Number.isNaN(Number(value.value))) { + return `${value.value}px` + } +}) + +let bareMilliseconds = bareValues((value: NamedUtilityValue) => { + if (!Number.isNaN(Number(value.value))) { + return `${value.value}ms` + } +}) + +let bareDegrees = bareValues((value: NamedUtilityValue) => { + if (!Number.isNaN(Number(value.value))) { + return `${value.value}deg` + } +}) + +function createCompatabilityConfig(theme: Theme): UserConfig { + return { + theme: { + accentColor: ({ theme }) => theme('colors'), + aspectRatio: bareValues((value) => { + if (value.fraction === null) return + let [lhs, rhs] = segment(value.fraction, '/') + if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return + return value.fraction + }), + backdropBlur: ({ theme }) => theme('blur'), + backdropBrightness: ({ theme }) => ({ + ...theme('brightness'), + ...barePercentages, + }), + backdropContrast: ({ theme }) => ({ + ...theme('contrast'), + ...barePercentages, + }), + backdropGrayscale: ({ theme }) => ({ + ...theme('grayscale'), + ...barePercentages, + }), + backdropHueRotate: ({ theme }) => ({ + ...theme('hueRotate'), + ...bareDegrees, + }), + backdropInvert: ({ theme }) => ({ + ...theme('invert'), + ...barePercentages, + }), + backdropOpacity: ({ theme }) => ({ + ...theme('opacity'), + ...barePercentages, + }), + backdropSaturate: ({ theme }) => ({ + ...theme('saturate'), + ...barePercentages, + }), + backdropSepia: ({ theme }) => ({ + ...theme('sepia'), + ...barePercentages, + }), + backgroundColor: ({ theme }) => theme('colors'), + backgroundOpacity: ({ theme }) => theme('opacity'), + border: barePixels, + borderColor: ({ theme }) => theme('colors'), + borderOpacity: ({ theme }) => theme('opacity'), + borderSpacing: ({ theme }) => theme('spacing'), + boxShadowColor: ({ theme }) => theme('colors'), + brightness: barePercentages, + caretColor: ({ theme }) => theme('colors'), + columns: bareIntegers, + contrast: barePercentages, + divideColor: ({ theme }) => theme('borderColor'), + divideOpacity: ({ theme }) => theme('borderOpacity'), + divideWidth: ({ theme }) => ({ + ...theme('borderWidth'), + ...barePixels, + }), + fill: ({ theme }) => theme('colors'), + flexBasis: ({ theme }) => theme('spacing'), + flexGrow: bareIntegers, + flexShrink: bareIntegers, + gap: ({ theme }) => theme('spacing'), + gradientColorStopPositions: barePercentages, + gradientColorStops: ({ theme }) => theme('colors'), + grayscale: barePercentages, + gridRowEnd: bareIntegers, + gridRowStart: bareIntegers, + gridTemplateColumns: bareValues((value) => { + if (!Number.isNaN(Number(value.value))) { + return `repeat(${value.value}, minmax(0, 1fr))` + } + }), + gridTemplateRows: bareValues((value) => { + if (!Number.isNaN(Number(value.value))) { + return `repeat(${value.value}, minmax(0, 1fr))` + } + }), + height: ({ theme }) => theme('spacing'), + hueRotate: bareDegrees, + inset: ({ theme }) => theme('spacing'), + invert: barePercentages, + lineClamp: bareIntegers, + margin: ({ theme }) => theme('spacing'), + maxHeight: ({ theme }) => theme('spacing'), + maxWidth: ({ theme }) => theme('spacing'), + minHeight: ({ theme }) => theme('spacing'), + minWidth: ({ theme }) => theme('spacing'), + opacity: barePercentages, + order: bareIntegers, + outlineColor: ({ theme }) => theme('colors'), + outlineOffset: barePixels, + outlineWidth: barePixels, + padding: ({ theme }) => theme('spacing'), + placeholderColor: ({ theme }) => theme('colors'), + placeholderOpacity: ({ theme }) => theme('opacity'), + ringColor: ({ theme }) => theme('colors'), + ringOffsetColor: ({ theme }) => theme('colors'), + ringOffsetWidth: barePixels, + ringOpacity: ({ theme }) => theme('opacity'), + ringWidth: barePixels, + rotate: bareDegrees, + saturate: barePercentages, + scale: barePercentages, + scrollMargin: ({ theme }) => theme('spacing'), + scrollPadding: ({ theme }) => theme('spacing'), + sepia: barePercentages, + size: ({ theme }) => theme('spacing'), + skew: bareDegrees, + space: ({ theme }) => theme('spacing'), + stroke: ({ theme }) => theme('colors'), + strokeWidth: barePixels, + textColor: ({ theme }) => theme('colors'), + textDecorationColor: ({ theme }) => theme('colors'), + textDecorationThickness: barePixels, + textIndent: ({ theme }) => theme('spacing'), + textOpacity: ({ theme }) => theme('opacity'), + textUnderlineOffset: barePixels, + transitionDelay: bareMilliseconds, + transitionDuration: { + DEFAULT: theme.get(['--default-transition-duration']) ?? null, + ...bareMilliseconds, + }, + transitionTimingFunction: { + DEFAULT: theme.get(['--default-transition-timing-function']) ?? null, + }, + translate: ({ theme }) => theme('spacing'), + width: ({ theme }) => theme('spacing'), + zIndex: bareIntegers, + }, + } } diff --git a/packages/tailwindcss/src/sort.test.ts b/packages/tailwindcss/src/sort.test.ts index 992c1d141..ca24fcf7d 100644 --- a/packages/tailwindcss/src/sort.test.ts +++ b/packages/tailwindcss/src/sort.test.ts @@ -68,7 +68,7 @@ test('can sort classes deterministically across multiple class lists', async () } }) -test('sorts arbitrary values across one or more class lists consistently', async () => { +test.only('sorts arbitrary values across one or more class lists consistently', async () => { let classes = [ ['[--fg:#fff]', '[--fg:#fff]'], ['[--bg:#111] [--bg_hover:#000] [--fg:#fff]', '[--bg:#111] [--bg_hover:#000] [--fg:#fff]'], diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 7d150c350..fc14e507c 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -144,6 +144,21 @@ export class Theme { return values } + + resolveNamespace(namespace: string) { + let values = new Map() + let prefix = `${namespace}-` + + for (let [key, value] of this.values) { + if (key === namespace) { + values.set(null, value.isInline ? value.value : this.#var(key)!) + } else if (key.startsWith(prefix)) { + values.set(key.slice(prefix.length), value.isInline ? value.value : this.#var(key)!) + } + } + + return values + } } export type ThemeKey = @@ -255,6 +270,7 @@ export type ThemeKey = | '--translate' | '--width' | '--z-index' + | `--default-${string}` export type ColorThemeKey = | '--color' diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 8880c8a46..a01e8473f 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -16092,7 +16092,7 @@ describe('legacy: matchUtilities', () => { `) }) - test('functional utilities with type: color and explicit modifiers', async () => { + test('functional utilities with explicit modifiers', async () => { async function run(candidates: string[]) { let compiled = await compile( css`